diff --git a/internal/api/server.go b/internal/api/server.go index 3556706..d720e96 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -60,6 +60,7 @@ func (a *apiServer) routes() http.Handler { r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) r.Use(viewerCookieMiddleware) + r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages // admin (#40): HTML page is open (key entry via form); API is key-guarded r.Get("/admin", a.ui.Handlers().HandleAdminPage) diff --git a/internal/web/securityheaders_test.go b/internal/web/securityheaders_test.go new file mode 100644 index 0000000..8ca47ed --- /dev/null +++ b/internal/web/securityheaders_test.go @@ -0,0 +1,56 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// #59: SecurityHeaders must add the CSP and hardening headers to rendered +// HTML responses only; JSON and /raw responses pass through untouched. +func TestSecurityHeaders(t *testing.T) { + pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte("ok")) + }) + h := SecurityHeaders(pages) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'" + if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP { + t.Errorf("CSP = %q, want %q", got, wantCSP) + } + if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Errorf("Referrer-Policy = %q, want no-referrer", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", got) + } + + // JSON response: no security headers. + jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + rec = httptest.NewRecorder() + jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil)) + if got := rec.Header().Get("Content-Security-Policy"); got != "" { + t.Errorf("unexpected CSP %q on JSON response", got) + } + if got := rec.Header().Get("Referrer-Policy"); got != "" { + t.Errorf("unexpected Referrer-Policy %q on JSON response", got) + } + + // Content type set after the first Write (as the inline can page does) is + // still picked up because headers are inspected post-handler. + lateh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + })) + rec = httptest.NewRecorder() + lateh.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") { + t.Errorf("CSP = %q, want frame-ancestors 'none'", got) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 9f36e8e..21e0fa2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -294,3 +294,23 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) { // Handlers builds a web.Handlers bound to this UI. func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} } + +// #59: security headers for rendered HTML pages. Applied wherever the +// response is text/html (page templates and the inline can page); JSON API +// responses and /raw content pass through untouched. script-src allows +// 'unsafe-inline' because the page templates carry inline scripts; CSP +// default-src 'self' still blocks external content and object/frame embeds, +// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34 +// pentest. Runs after the handler so the Content-Type is already set. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + h := w.Header() + if strings.HasPrefix(h.Get("Content-Type"), "text/html") { + h.Set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Content-Type-Options", "nosniff") + } + }) +}