feat: 添加安全中间件、优化限流器与API密钥验证

1. 新增安全响应头中间件强化请求安全性
2. 使用恒定时间比较修复API密钥验证时序漏洞
3. 新增限流器条目上限配置与自动清理逻辑
4. 优化CORS日志与客户端IP获取逻辑
5. 升级Go构建环境与基础镜像版本
6. 新增非root用户运行容器增强安全性
This commit is contained in:
sun
2026-05-24 15:33:27 +08:00
parent 9c35f780db
commit 9b2a1d1531
8 changed files with 53 additions and 13 deletions
+8 -3
View File
@@ -1,4 +1,4 @@
FROM golang:1.19-alpine AS builder FROM golang:1.23-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -9,15 +9,20 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o tts-api . RUN CGO_ENABLED=0 GOOS=linux go build -o tts-api .
FROM alpine:3.18 FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata \
&& addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app WORKDIR /app
COPY --from=builder /app/tts-api . COPY --from=builder /app/tts-api .
COPY --from=builder /app/health.html . COPY --from=builder /app/health.html .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+2 -1
View File
@@ -1,4 +1,4 @@
package common package common
import "time" import "time"
@@ -17,4 +17,5 @@ const (
MaxConcurrentRequests = 10 MaxConcurrentRequests = 10
CleanupInterval = time.Hour CleanupInterval = time.Hour
MaxModelNameLength = 64 MaxModelNameLength = 64
MaxRateLimiterEntries = 100000
) )
+2 -1
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
@@ -39,7 +40,7 @@ func ValidateAPIKey(r *http.Request) bool {
token := strings.TrimPrefix(authHeader, "Bearer ") token := strings.TrimPrefix(authHeader, "Bearer ")
for _, validKey := range validAPIKeys { for _, validKey := range validAPIKeys {
if token == validKey { if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
return true return true
} }
} }
+3 -6
View File
@@ -96,16 +96,13 @@ func CORS(next http.Handler) http.Handler {
} else if !strings.Contains(vary, "Origin") { } else if !strings.Contains(vary, "Origin") {
w.Header().Set("Vary", vary+", Origin") w.Header().Set("Vary", vary+", Origin")
} }
} else {
log.Printf("CORS拦截: 来源=%q 路径=%s 方法=%s 客户端=%s",
origin, r.URL.Path, r.Method, GetClientIP(r))
} }
} }
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
if origin != "" {
if _, matched := matchOrigin(origin); !matched {
w.WriteHeader(http.StatusNoContent)
return
}
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
+16 -2
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"log"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -77,6 +78,16 @@ func (rl *RateLimiter) cleanup() {
rl.requests[k] = valid rl.requests[k] = valid
} }
} }
if len(rl.requests) > common.MaxRateLimiterEntries {
log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries)
for k := range rl.requests {
if len(rl.requests) <= common.MaxRateLimiterEntries/2 {
break
}
delete(rl.requests, k)
}
}
} }
func GetClientIP(r *http.Request) string { func GetClientIP(r *http.Request) string {
@@ -84,11 +95,14 @@ func GetClientIP(r *http.Request) string {
if xForwardedFor != "" { if xForwardedFor != "" {
ips := strings.Split(xForwardedFor, ",") ips := strings.Split(xForwardedFor, ",")
if len(ips) > 0 { if len(ips) > 0 {
return strings.TrimSpace(ips[0]) ip := strings.TrimSpace(ips[0])
if ip != "" {
return ip
}
} }
} }
xRealIP := r.Header.Get("X-Real-IP") xRealIP := strings.TrimSpace(r.Header.Get("X-Real-IP"))
if xRealIP != "" { if xRealIP != "" {
return xRealIP return xRealIP
} }
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"net/http"
"strings"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if strings.HasPrefix(r.URL.Path, "/v1/") || r.URL.Path == "/health" {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
+1
View File
@@ -12,6 +12,7 @@ func Setup() *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
r.Use(middleware.CORS) r.Use(middleware.CORS)
r.Use(middleware.SecurityHeaders)
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
BIN
View File
Binary file not shown.