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"))
|
||||
}
|
||||
Reference in New Issue
Block a user