package web import ( "bytes" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "github.com/jleight/meshtender/internal/config" ) func TestSecurityHeadersCSPNonce(t *testing.T) { t.Parallel() var seen []string h := (&Env{}).securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // The nonce in the header must match the one templates read from context. seen = append(seen, NonceFromContext(r.Context())) })) rec1 := httptest.NewRecorder() h.ServeHTTP(rec1, httptest.NewRequest(http.MethodGet, "/", nil)) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/", nil)) csp := rec1.Header().Get("Content-Security-Policy") if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") { t.Fatalf("CSP missing base directives: %q", csp) } if !strings.Contains(csp, "script-src 'self' 'nonce-"+seen[0]+"'") { t.Fatalf("CSP script-src doesn't carry the context nonce %q: %q", seen[0], csp) } if rec1.Header().Get("X-Content-Type-Options") != "nosniff" { t.Errorf("missing X-Content-Type-Options: nosniff") } if rec1.Header().Get("X-Frame-Options") != "DENY" { t.Errorf("missing X-Frame-Options: DENY") } if rec1.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" { t.Errorf("missing Cross-Origin-Opener-Policy: same-origin") } // Permissions-Policy must keep the features we actually use. pp := rec1.Header().Get("Permissions-Policy") if !strings.Contains(pp, "serial=(self)") || !strings.Contains(pp, "publickey-credentials-get=(self)") { t.Errorf("Permissions-Policy doesn't allow serial/webauthn: %q", pp) } if seen[0] == "" || seen[1] == "" || seen[0] == seen[1] { t.Errorf("nonce not fresh per request: %q, %q", seen[0], seen[1]) } } // TestCSPFormActionAllowsSiblingSurfaces is the regression test for a bug that broke // password sign-in and sign-up in Chrome while every server-side test passed. // // Credential POSTs land on the auth host and answer 303 to the app host's handoff. // Chrome enforces form-action across the redirect chain (the spec says it shouldn't, // and Firefox doesn't), so `form-action 'self'` made the browser refuse to follow that // redirect. The POST still arrived and the handler still succeeded — the server logged a // clean 303 — so the only visible symptom was a button that did nothing, and nothing // server-side looked wrong at all. // // The port matters as much as the host: a source expression without one only matches // 443, so a dev deployment on :8080 needs the port present or it's blocked all over // again. func TestCSPFormActionAllowsSiblingSurfaces(t *testing.T) { t.Parallel() cfg := &config.Config{ PrimaryHost: "app.example.test", AuthHost: "auth.example.test", RootHost: "example.test", Secure: true, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/login", nil) req.Host = "auth.example.test:8443" (&Env{Cfg: cfg}).securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})). ServeHTTP(rec, req) csp := rec.Header().Get("Content-Security-Policy") for _, want := range []string{ "'self'", "https://app.example.test:8443", // the handoff target — the one that was blocked "https://auth.example.test:8443", // where credential forms live "https://example.test:8443", // the root beacon } { if !strings.Contains(csp, want) { t.Errorf("form-action missing %q: %q", want, csp) } } // Still a closed list — a foreign origin must not be able to receive our forms. if strings.Contains(csp, "form-action *") || strings.Contains(csp, "form-action 'unsafe") { t.Errorf("form-action was widened to a wildcard: %q", csp) } } func TestSecurityHeadersHSTSGatedOnTLS(t *testing.T) { t.Parallel() // No TLS (nil/insecure config): no HSTS. rec := httptest.NewRecorder() (&Env{}).securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})). ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Header().Get("Strict-Transport-Security") != "" { t.Errorf("HSTS set without TLS: %q", rec.Header().Get("Strict-Transport-Security")) } // TLS on: HSTS present. rec = httptest.NewRecorder() (&Env{Cfg: &config.Config{Secure: true}}). securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})). ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if !strings.HasPrefix(rec.Header().Get("Strict-Transport-Security"), "max-age=") { t.Errorf("HSTS missing under TLS: %q", rec.Header().Get("Strict-Transport-Security")) } } // TestTemplatesHaveNoInlineJS enforces the CSP-compatible pattern across every // template: no inline event handlers (on*=), and no un-nonce'd inline