This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
@@ -0,0 +1,91 @@
package middleware
import (
"net/http"
"strconv"
"strings"
)
// CORSConfig configures the CORS middleware. AllowedOrigins is an explicit
// allowlist (no "*" with credentials — that combination is rejected by
// browsers); an entry of "*" disables credentials and echoes "*". When the
// request Origin is not in the allowlist no CORS headers are emitted, so the
// browser blocks the cross-origin read (same as having no CORS support).
type CORSConfig struct {
Enabled bool
AllowedOrigins []string
AllowedMethods []string // empty → GET,POST,PUT,PATCH,DELETE,OPTIONS
AllowedHeaders []string // empty → Authorization,Content-Type,X-Client-Request-ID
AllowCredentials bool
MaxAgeSeconds int // preflight cache; 0 → 600
}
// CORS returns middleware that handles cross-origin requests against an explicit
// origin allowlist and short-circuits OPTIONS preflight with 204. When
// cfg.Enabled is false it is a transparent pass-through.
func CORS(cfg CORSConfig) func(http.Handler) http.Handler {
methods := strings.Join(orDefault(cfg.AllowedMethods,
[]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), ", ")
headers := strings.Join(orDefault(cfg.AllowedHeaders,
[]string{"Authorization", "Content-Type", "X-Client-Request-ID"}), ", ")
maxAge := cfg.MaxAgeSeconds
if maxAge <= 0 {
maxAge = 600
}
allowAll := false
allow := map[string]struct{}{}
for _, o := range cfg.AllowedOrigins {
if o == "*" {
allowAll = true
}
allow[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.Enabled {
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allow, allowAll) {
h := w.Header()
// Echo the specific origin (not "*") so credentials can flow;
// vary on Origin so caches don't serve the wrong ACAO.
if allowAll && !cfg.AllowCredentials {
h.Set("Access-Control-Allow-Origin", "*")
} else {
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
}
if cfg.AllowCredentials {
h.Set("Access-Control-Allow-Credentials", "true")
}
h.Set("Access-Control-Allow-Methods", methods)
h.Set("Access-Control-Allow-Headers", headers)
h.Set("Access-Control-Max-Age", strconv.Itoa(maxAge))
}
// Preflight: answer and stop.
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
func originAllowed(origin string, allow map[string]struct{}, allowAll bool) bool {
if allowAll {
return true
}
_, ok := allow[origin]
return ok
}
func orDefault(v, def []string) []string {
if len(v) == 0 {
return def
}
return v
}
@@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestCORS_AllowedOriginGetsHeaders(t *testing.T) {
h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}, AllowCredentials: true})(okHandler())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, "https://app.example.com", rr.Header().Get("Access-Control-Allow-Origin"))
require.Equal(t, "true", rr.Header().Get("Access-Control-Allow-Credentials"))
require.Equal(t, http.StatusOK, rr.Code)
}
func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) {
h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}})(okHandler())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.example.com")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin"))
}
func TestCORS_PreflightReturns204(t *testing.T) {
h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}})(okHandler())
req := httptest.NewRequest(http.MethodOptions, "/api/v1/x", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
require.Equal(t, "https://app.example.com", rr.Header().Get("Access-Control-Allow-Origin"))
require.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST")
}
func TestCORS_WildcardWithoutCredentials(t *testing.T) {
h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"*"}})(okHandler())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://anything.example.com")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
}
func TestCORS_DisabledIsPassthrough(t *testing.T) {
h := CORS(CORSConfig{Enabled: false})(okHandler())
req := httptest.NewRequest(http.MethodOptions, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "disabled CORS must not short-circuit OPTIONS")
require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin"))
}
@@ -0,0 +1,78 @@
package middleware
import "net/http"
// SecurityHeadersConfig configures the SecurityHeaders middleware. All headers
// are opt-in via cfg so they can be tuned (or disabled) without code changes;
// the defaults are safe for an SPA + WebSocket app. HSTS is gated on TLS — it is
// only emitted when TLSEnabled is true, since advertising HSTS over plain HTTP
// either does nothing or (once cached) breaks local http access.
type SecurityHeadersConfig struct {
Enabled bool
TLSEnabled bool
// HSTSMaxAgeSeconds is the Strict-Transport-Security max-age. 0 → default
// (1 year) when TLSEnabled.
HSTSMaxAgeSeconds int
// FrameOptions sets X-Frame-Options; empty → "DENY".
FrameOptions string
// ReferrerPolicy sets Referrer-Policy; empty → "no-referrer".
ReferrerPolicy string
// ContentSecurityPolicy sets CSP; empty → header omitted (CSP can break the
// SPA + WS handshake if mis-set, so it is off by default and opt-in).
ContentSecurityPolicy string
}
// SecurityHeaders returns middleware that stamps standard security response
// headers. When cfg.Enabled is false it is a transparent pass-through, so it can
// be wired unconditionally and toggled by config.
func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler {
frame := cfg.FrameOptions
if frame == "" {
frame = "DENY"
}
referrer := cfg.ReferrerPolicy
if referrer == "" {
referrer = "no-referrer"
}
hstsAge := cfg.HSTSMaxAgeSeconds
if hstsAge <= 0 {
hstsAge = 31536000 // 1 year
}
hstsValue := "max-age=" + itoa(hstsAge) + "; includeSubDomains"
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.Enabled {
next.ServeHTTP(w, r)
return
}
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", frame)
h.Set("Referrer-Policy", referrer)
if cfg.ContentSecurityPolicy != "" {
h.Set("Content-Security-Policy", cfg.ContentSecurityPolicy)
}
if cfg.TLSEnabled {
h.Set("Strict-Transport-Security", hstsValue)
}
next.ServeHTTP(w, r)
})
}
}
// itoa is a tiny non-allocating-ish int->string for positive values, avoiding a
// strconv import for a single use.
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
func TestSecurityHeaders_PresentWhenEnabled(t *testing.T) {
h := SecurityHeaders(SecurityHeadersConfig{Enabled: true})(okHandler())
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options"))
require.Equal(t, "DENY", rr.Header().Get("X-Frame-Options"))
require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"))
require.Empty(t, rr.Header().Get("Strict-Transport-Security"), "HSTS only under TLS")
}
func TestSecurityHeaders_HSTSOnlyUnderTLS(t *testing.T) {
h := SecurityHeaders(SecurityHeadersConfig{Enabled: true, TLSEnabled: true})(okHandler())
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
require.Contains(t, rr.Header().Get("Strict-Transport-Security"), "max-age=31536000")
}
func TestSecurityHeaders_DisabledIsPassthrough(t *testing.T) {
h := SecurityHeaders(SecurityHeadersConfig{Enabled: false})(okHandler())
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
require.Empty(t, rr.Header().Get("X-Content-Type-Options"))
require.Equal(t, http.StatusOK, rr.Code)
}
func TestSecurityHeaders_CSPOptIn(t *testing.T) {
h := SecurityHeaders(SecurityHeadersConfig{Enabled: true, ContentSecurityPolicy: "default-src 'self'"})(okHandler())
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy"))
}
+42
View File
@@ -25,6 +25,18 @@ type RouterDeps struct {
SPAHandler http.Handler
// Logger 用于 Recover 和 Logger 中间件。为 nil 时跳过日志相关中间件。
Logger *slog.Logger
// Security 是安全响应头中间件配置(cfg-gated;Enabled=false 时透传)。
Security middleware.SecurityHeadersConfig
// CORS 是跨域中间件配置(cfg-gated;Enabled=false 时透传)。默认 off,
// 避免误配破坏 SPA + WS 握手;需要时显式开启并配置 origin 白名单。
CORS middleware.CORSConfig
// MetricsHandler 为 nil 时不挂 /metrics;非 nil 时挂到 GET /metrics。
// 暴露成本/会话数,必须经 MetricsAuthToken 门禁(见 NewRouter)。
MetricsHandler http.Handler
// MetricsAuthToken 非空时,/metrics 要求 Authorization: Bearer <token>
// 或 ?token=<token> 匹配,否则 401。为空时 /metrics 不做令牌校验(仅
// 适用于绑定内网 / 由反代鉴权的部署)。
MetricsAuthToken string
}
// NewRouter builds the application's chi.Router with the standard
@@ -36,6 +48,10 @@ func NewRouter(deps RouterDeps) chi.Router {
// 所有中间件必须在注册路由之前添加(chi 的强制要求)。
r.Use(middleware.RequestID)
// 安全头与 CORS 紧随 RequestID、在路由挂载之前;二者均 cfg-gated,
// Enabled=false 时为透传,默认行为与历史一致。
r.Use(middleware.SecurityHeaders(deps.Security))
r.Use(middleware.CORS(deps.CORS))
if deps.Logger != nil {
r.Use(middleware.Recover(deps.Logger))
r.Use(middleware.Logger(deps.Logger))
@@ -61,9 +77,35 @@ func NewRouter(deps RouterDeps) chi.Router {
_, _ = w.Write([]byte("ok"))
})
// /metrics(Prometheus):admin-gated via static bearer token. 挂在 SPA
// NotFound 之前,确保未匹配请求不会落到 SPA fallback。
if deps.MetricsHandler != nil {
token := deps.MetricsAuthToken
mh := deps.MetricsHandler
r.Get("/metrics", func(w http.ResponseWriter, req *http.Request) {
if token != "" && !metricsTokenOK(req, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
mh.ServeHTTP(w, req)
})
}
if deps.SPAHandler != nil {
r.NotFound(deps.SPAHandler.ServeHTTP)
}
return r
}
// metricsTokenOK 校验 /metrics 请求是否携带匹配的令牌(Authorization: Bearer
// <token> 或 ?token=<token>)。
func metricsTokenOK(req *http.Request, token string) bool {
const prefix = "Bearer "
if h := req.Header.Get("Authorization"); len(h) > len(prefix) && h[:len(prefix)] == prefix {
if h[len(prefix):] == token {
return true
}
}
return req.URL.Query().Get("token") == token
}
@@ -0,0 +1,71 @@
package httpx
import (
"net/http"
"net/http/httptest"
"testing"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("metric_x 1\n"))
})
}
func TestMetricsNotMountedWhenNil(t *testing.T) {
r := NewRouter(RouterDeps{})
req := httptest.NewRequest("GET", "/metrics", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// No /metrics route and no SPA fallback -> 404.
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (no metrics handler)", rec.Code)
}
}
func TestMetricsOpenWhenNoToken(t *testing.T) {
r := NewRouter(RouterDeps{MetricsHandler: okHandler()})
req := httptest.NewRequest("GET", "/metrics", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestMetricsTokenGated(t *testing.T) {
r := NewRouter(RouterDeps{MetricsHandler: okHandler(), MetricsAuthToken: "secret"})
// no token -> 401
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("no-token status = %d, want 401", rec.Code)
}
// wrong token -> 401
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics", nil)
req.Header.Set("Authorization", "Bearer nope")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong-token status = %d, want 401", rec.Code)
}
// correct bearer -> 200
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/metrics", nil)
req.Header.Set("Authorization", "Bearer secret")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer status = %d, want 200", rec.Code)
}
// correct query token -> 200
rec = httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics?token=secret", nil))
if rec.Code != http.StatusOK {
t.Fatalf("query-token status = %d, want 200", rec.Code)
}
}