SecurityHeaders middleware widened its CSP to allow Next.js inline scripts + data:/blob: images (platform/internal/middleware/securityheaders.go:44, canvas is reverse-proxied through the gin stack so it needs the permissive policy). The two CSP asserts in securityheaders_test.go still hard-compared against the old tight `default-src 'self'`, so they fail on main as of this afternoon. Fix: assert each expected CSP fragment is PRESENT in the header (substring match) instead of byte-for-byte equality. Test intent is "CSP is set, starts with tight default-src, contains the expected directives" — not "CSP matches this exact string". Future subsource tuning (add a new CDN, bump blob:/data: scope) won't re-break this test. Caught because every PR touching anything in the monorepo currently fails the Platform (Go) CI job on these two asserts. Fixing on a dedicated branch so it can land ahead of every blocked PR in the queue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
}
|
|
|
|
func TestSecurityHeaders(t *testing.T) {
|
|
r := gin.New()
|
|
r.Use(SecurityHeaders())
|
|
r.GET("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
tests := []struct {
|
|
header string
|
|
want string
|
|
}{
|
|
{"X-Content-Type-Options", "nosniff"},
|
|
{"X-Frame-Options", "DENY"},
|
|
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
|
|
// #282: regression guards for the two headers that were
|
|
// documented in CLAUDE.md but missing from the implementation.
|
|
{"Referrer-Policy", "strict-origin-when-cross-origin"},
|
|
{"Permissions-Policy", "camera=(), microphone=(), geolocation=()"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := w.Header().Get(tt.header)
|
|
if got != tt.want {
|
|
t.Errorf("header %s = %q, want %q", tt.header, got, tt.want)
|
|
}
|
|
}
|
|
|
|
// CSP: widened to allow Next.js inline scripts/styles + data:/blob:
|
|
// images because the canvas is reverse-proxied through the same
|
|
// gin middleware stack. Assert the policy starts with the tight
|
|
// default-src and contains each expected directive — exact-match
|
|
// would brittle-break every time we tune a subsource list.
|
|
csp := w.Header().Get("Content-Security-Policy")
|
|
for _, fragment := range []string{
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob:",
|
|
"connect-src 'self' ws: wss:",
|
|
"font-src 'self' data:",
|
|
} {
|
|
if !strings.Contains(csp, fragment) {
|
|
t.Errorf("CSP missing expected fragment %q (full CSP: %q)", fragment, csp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersPresenceOnMultipleRoutes(t *testing.T) {
|
|
r := gin.New()
|
|
r.Use(SecurityHeaders())
|
|
r.GET("/a", func(c *gin.Context) { c.String(http.StatusOK, "a") })
|
|
r.POST("/b", func(c *gin.Context) { c.String(http.StatusCreated, "b") })
|
|
|
|
// GET /a
|
|
w1 := httptest.NewRecorder()
|
|
req1, _ := http.NewRequest(http.MethodGet, "/a", nil)
|
|
r.ServeHTTP(w1, req1)
|
|
|
|
if v := w1.Header().Get("X-Frame-Options"); v != "DENY" {
|
|
t.Errorf("GET /a: X-Frame-Options = %q, want DENY", v)
|
|
}
|
|
|
|
// POST /b
|
|
w2 := httptest.NewRecorder()
|
|
req2, _ := http.NewRequest(http.MethodPost, "/b", nil)
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if v := w2.Header().Get("X-Content-Type-Options"); v != "nosniff" {
|
|
t.Errorf("POST /b: X-Content-Type-Options = %q, want nosniff", v)
|
|
}
|
|
if v := w2.Header().Get("Strict-Transport-Security"); v != "max-age=31536000; includeSubDomains" {
|
|
t.Errorf("POST /b: Strict-Transport-Security = %q, want max-age=31536000; includeSubDomains", v)
|
|
}
|
|
// Fragment-match rather than exact — CSP subsource lists get tuned
|
|
// without changing the security posture. Test intent is "CSP is
|
|
// present + starts with the tight default-src", not "CSP matches
|
|
// this exact byte-for-byte string".
|
|
csp := w2.Header().Get("Content-Security-Policy")
|
|
if !strings.Contains(csp, "default-src 'self'") {
|
|
t.Errorf("POST /b: CSP missing default-src 'self' (full: %q)", csp)
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersDoNotOverrideExisting(t *testing.T) {
|
|
r := gin.New()
|
|
r.Use(SecurityHeaders())
|
|
r.GET("/custom", func(c *gin.Context) {
|
|
// Handler sets its own X-Frame-Options — SecurityHeaders runs before
|
|
// the handler, so the handler's value will take precedence.
|
|
c.Header("X-Frame-Options", "SAMEORIGIN")
|
|
c.String(http.StatusOK, "custom")
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/custom", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// The handler's value should be present (may override middleware's)
|
|
got := w.Header().Get("X-Frame-Options")
|
|
if got != "SAMEORIGIN" {
|
|
t.Errorf("expected handler override SAMEORIGIN, got %q", got)
|
|
}
|
|
}
|