aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-18 13:49:19 +0300
committergrm <grm@eyesin.space>2026-09-18 13:49:28 +0300
commitd23fe805546e992c8033d64d7177fe1454ad7716 (patch)
tree79657033b96f852f57bac5c0bf42551a8721afa8 /internal
parentabc1898daebae33405688618caffa01cece3b850 (diff)
downloadblogspace-d23fe805546e992c8033d64d7177fe1454ad7716.tar.gz
blogspace-d23fe805546e992c8033d64d7177fe1454ad7716.tar.bz2
blogspace-d23fe805546e992c8033d64d7177fe1454ad7716.zip
Security: Send security headers; refuse to frame the dashboard
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
-rw-r--r--internal/web/server.go34
-rw-r--r--internal/web/web_test.go31
2 files changed, 62 insertions, 3 deletions
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)
+ }
+ }
+}