diff options
| -rw-r--r-- | AGENTS.md | 9 | ||||
| -rw-r--r-- | internal/web/server.go | 34 | ||||
| -rw-r--r-- | internal/web/web_test.go | 31 |
3 files changed, 70 insertions, 4 deletions
@@ -147,7 +147,14 @@ internal/web/ server.go (host router, middleware, render helpers) logins are logged with the username and address, and the login body is capped at `maxLoginBody` (64 KB) since it is the one POST outside `guardPOST`. `s.clientIP` is the peer address, or the last - `X-Forwarded-For` hop with `TRUST_PROXY` (earlier entries are forgeable). Flood + `X-Forwarded-For` hop with `TRUST_PROXY` (earlier entries are forgeable). + `secureHeaders` (`ServeHTTP`) puts `nosniff`, a `Referrer-Policy` and, + with `HTTPS`, HSTS on every response; the management paths on the root + host (`managementPaths` — add a new top-level management prefix there + as well as to `reservedPageSlugs`) also get `X-Frame-Options: DENY` and + a CSP of `frame-ancestors`/`base-uri`/`form-action`/`object-src` only — + no `script-src`, the dashboard's inline scripts are a product + constraint. Public blog pages carry no framing rule (owner content). Flood control for everything else stays at the reverse proxy (`limit_req`). - **Templates**: each page file is parsed together with its layout (`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all diff --git a/internal/web/server.go b/internal/web/server.go index 5a6ac4e..6009310 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -43,12 +43,16 @@ func NewServer(cfg *config.Config, st *store.Store) *Server { // site plus the superadmin's root blog, one label below it is a blog, anything // else is a 404. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if s.cfg.HTTPS { // every subdomain is ours, so the whole site may pin https - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - } + s.secureHeaders(w, r) host := hostname(r.Host) switch { case host == s.cfg.BaseDomain, host == config.RootSubdomain+"."+s.cfg.BaseDomain: + if managementPath(r.URL.Path) { + // Nothing may frame the dashboard or send its forms elsewhere. No + // script-src: its inline scripts are a product constraint. + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'") + } // The root blog is looked up lazily by hostBlog so management pages work even without one. s.root.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxHostSub, config.RootSubdomain))) case strings.HasSuffix(host, "."+s.cfg.BaseDomain): @@ -63,6 +67,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// secureHeaders is what every response carries. Public blog pages get no +// framing rule: they are the owner's content and may be embedded on purpose. +func (s *Server) secureHeaders(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + if s.cfg.HTTPS { // every subdomain is ours, so the whole site may pin https + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } +} + +// managementPaths are the root-domain prefixes of the login and dashboard +// pages (everything else on that host is the root blog). +var managementPaths = []string{"/webadmin", "/logout", "/dashboard", "/account/", "/b/", "/admin/"} + +func managementPath(p string) bool { + for _, m := range managementPaths { + if strings.HasPrefix(p, m) { + return true + } + } + return false +} + func hostname(h string) string { if host, _, err := net.SplitHostPort(h); err == nil { h = host 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) + } + } +} |
