Scout/internal/httpapi/security.go
2026-08-31 00:06:06 +08:00

167 lines
4.8 KiB
Go

package httpapi
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
type rateLimitEntry struct {
windowStart time.Time
count int
}
type rateLimiter struct {
mu sync.Mutex
limit int
entries map[string]rateLimitEntry
}
func (s *Server) hostGuard(next http.Handler) http.Handler {
if len(s.AllowedHosts) == 0 && !s.ForceHTTPS {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if len(s.AllowedHosts) > 0 && !hostAllowed(request.Host, s.AllowedHosts) {
writeError(writer, http.StatusMisdirectedRequest, "请求域名不在允许列表中")
return
}
if s.ForceHTTPS && requestURLScheme(request, s.TrustProxy) != "https" {
writeError(writer, http.StatusBadRequest, "当前服务要求使用 HTTPS")
return
}
next.ServeHTTP(writer, request)
})
}
func (s *Server) rateLimit(next http.Handler) http.Handler {
if s.RateLimitPerMinute <= 0 {
return next
}
limiter := &rateLimiter{limit: s.RateLimitPerMinute, entries: make(map[string]rateLimitEntry)}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
key := clientAddress(request, s.TrustProxy)
now := time.Now()
if !limiter.allow(key, now) {
writer.Header().Set("Retry-After", "60")
writeError(writer, http.StatusTooManyRequests, "请求过于频繁,请稍后再试")
return
}
next.ServeHTTP(writer, request)
})
}
func (limiter *rateLimiter) allow(key string, now time.Time) bool {
limiter.mu.Lock()
defer limiter.mu.Unlock()
entry, ok := limiter.entries[key]
if !ok || now.Sub(entry.windowStart) >= time.Minute {
limiter.entries[key] = rateLimitEntry{windowStart: now, count: 1}
if len(limiter.entries) > 10000 {
for address, current := range limiter.entries {
if now.Sub(current.windowStart) >= time.Minute {
delete(limiter.entries, address)
}
}
}
return true
}
if entry.count >= limiter.limit {
return false
}
entry.count++
limiter.entries[key] = entry
return true
}
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
origin := strings.TrimSpace(request.Header.Get("Origin"))
allowed := origin == ""
if len(s.AllowedOrigins) == 0 {
if origin != "" {
writer.Header().Set("Access-Control-Allow-Origin", "*")
}
allowed = true
} else {
for _, configured := range s.AllowedOrigins {
if origin == configured {
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Add("Vary", "Origin")
allowed = true
break
}
}
}
if origin != "" && !allowed {
writeError(writer, http.StatusForbidden, "访问来源不在允许列表中")
return
}
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
writer.Header().Set("Access-Control-Max-Age", "600")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
writer.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
next.ServeHTTP(writer, request)
})
}
func hostAllowed(value string, allowed []string) bool {
host := strings.ToLower(strings.TrimSpace(value))
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
host = strings.TrimSuffix(host, ".")
for _, candidate := range allowed {
candidate = strings.ToLower(strings.TrimSpace(candidate))
candidate = strings.TrimSuffix(candidate, ".")
if candidate == host || candidate == "*" {
return true
}
if strings.HasPrefix(candidate, "*.") && strings.HasSuffix(host, candidate[1:]) {
return true
}
}
return false
}
func requestURLScheme(request *http.Request, trustProxy bool) string {
if trustProxy {
if value := strings.ToLower(strings.TrimSpace(request.Header.Get("X-Forwarded-Proto"))); value != "" {
return value
}
}
if request.TLS != nil {
return "https"
}
return "http"
}
func clientAddress(request *http.Request, trustProxy bool) string {
if trustProxy {
if forwarded := strings.TrimSpace(strings.Split(request.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
}
value := strings.TrimSpace(request.RemoteAddr)
if host, _, err := net.SplitHostPort(value); err == nil {
return host
}
return value
}