You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user