From d23fe805546e992c8033d64d7177fe1454ad7716 Mon Sep 17 00:00:00 2001 From: grm Date: Fri, 18 Sep 2026 13:49:19 +0300 Subject: Security: Send security headers; refuse to frame the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages carried no X-Content-Type-Options, no Referrer-Policy, and the dashboard could be framed by any site (clickjacking a superadmin's delete buttons). Every response now says nosniff and strict-origin-when-cross-origin, and the management paths on the root host add X-Frame-Options: DENY plus a CSP of frame-ancestors, base-uri, form-action and object-src — the directives that do not touch the dashboard's inline scripts, which are a product constraint. Blog pages get no framing rule: they are the owner's content and may be embedded on purpose. HSTS moves into the same helper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/web_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'internal/web/web_test.go') diff --git a/internal/web/web_test.go b/internal/web/web_test.go index b1609cd..dfc9629 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1049,3 +1049,34 @@ func TestCrossSiteForm(t *testing.T) { t.Errorf("cross-site login: got %d, want 403", rec.Code) } } + +// Every response says nosniff and a referrer policy; only the management pages refuse to be framed. +func TestSecurityHeaders(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + get := func(host, path string) http.Header { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", path, nil) + req.Host = host + s.ServeHTTP(rec, req) + return rec.Header() + } + h := get("example.com", "/webadmin") + if h.Get("X-Content-Type-Options") != "nosniff" || h.Get("Referrer-Policy") == "" { + t.Errorf("login page headers: %v", h) + } + if h.Get("X-Frame-Options") != "DENY" || !strings.Contains(h.Get("Content-Security-Policy"), "frame-ancestors 'none'") { + t.Errorf("login page not frame-protected: %v", h) + } + if h.Get("Strict-Transport-Security") != "" { + t.Error("HSTS sent without HTTPS") + } + h = get("evil.com", "/") // even the unknown-host 404 carries the common ones + if h.Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("404 headers: %v", h) + } + for _, p := range []string{"/static/blog.css", "/webadmin"} { // the blog host may be framed (these need no DB) + if h := get("a.example.com", p); h.Get("X-Frame-Options") != "" || h.Get("Content-Security-Policy") != "" { + t.Errorf("%s on a blog host is frame-protected", p) + } + } +} -- cgit v1.2.3