server: add a nonce-based CSP + security headers (#96)
The server set no Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, or Referrer-Policy on any response. For an app whose entire model is "the browser holds the only keys," a CSP is the containment layer if the no-innerHTML-of- untrusted-content rule is ever broken: an injected inline script could read the private key out of localStorage and exfiltrate it. - securityHeaders middleware wraps the router: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, and HSTS (ignored by browsers over plain-HTTP local dev; applies in prod where every instance is TLS). - servePage emits a per-request nonce CSP; the pages' inline <script> blocks carry a matching nonce, so only they run and any injected inline script is blocked. script/connect/default are 'self'; img-src allows https+blob+data (link-preview images, avatars); frame-src is limited to youtube-nocookie for the video embed.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+121
−6
main.go+45 −1security_headers_test.go+71 −0web/pages/admin.html+1 −1web/pages/index.html+2 −2web/pages/join.html+2 −2
diff --git a/main.go b/main.go| index 721de0c..795a17a 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1,9 +1,11 @@ |
| package main |
| import ( |
| + "crypto/rand" |
| "crypto/sha256" |
| "database/sql" |
| "embed" |
| + "encoding/base64" |
| "encoding/json" |
| "flag" |
| "fmt" |
| @@ -205,7 +207,34 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /sw.js", s.serveSW) |
| mux.HandleFunc("GET /manifest.webmanifest", s.serveManifest) |
| - return mux |
| + return securityHeaders(mux) |
| +} |
| + |
| +// securityHeaders adds defense-in-depth headers to every response. The page |
| +// Content-Security-Policy needs a per-request nonce, so it's set in servePage; |
| +// these are the static ones that apply everywhere (including the blob download, |
| +// where nosniff stops a mistyped blob being sniffed as HTML). |
| +func securityHeaders(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + h := w.Header() |
| + h.Set("X-Content-Type-Options", "nosniff") |
| + h.Set("X-Frame-Options", "DENY") |
| + h.Set("Referrer-Policy", "no-referrer") |
| + // Ignored by browsers over plain HTTP (e.g. local dev); applies in prod |
| + // where every instance is served over TLS. |
| + h.Set("Strict-Transport-Security", "max-age=31536000") |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// cspNonce returns a fresh per-response nonce for the pages' inline <script> |
| +// tags, so the CSP can allow exactly those and reject any injected script. |
| +func cspNonce() string { |
| + var b [16]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "" // fail closed: no inline script will match an empty nonce |
| + } |
| + return base64.StdEncoding.EncodeToString(b[:]) |
| } |
| // ---- pages -------------------------------------------------------------- |
| @@ -229,6 +258,20 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| http.NotFound(w, r) |
| return |
| } |
| + // A per-request nonce whitelists exactly the pages' own inline <script> |
| + // blocks, so an injected inline script (the highest-value attack here — it |
| + // could read the private key out of localStorage) is blocked. Third-party |
| + // image/frame sources are limited to what the app actually loads (preview |
| + // images over https, YouTube's no-cookie embed); everything else is 'self'. |
| + nonce := cspNonce() |
| + w.Header().Set("Content-Security-Policy", |
| + "default-src 'self'; "+ |
| + "script-src 'self' 'nonce-"+nonce+"'; "+ |
| + "style-src 'self' 'unsafe-inline'; "+ |
| + "img-src 'self' data: blob: https:; "+ |
| + "connect-src 'self'; "+ |
| + "frame-src https://www.youtube-nocookie.com; "+ |
| + "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| // Inject the configurable group name (HTML-escaped — it's admin-set) and the |
| // build version (so the page knows which frontend it loaded — see /api/version). |
| @@ -237,6 +280,7 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| // Whether ordinary members may invite people (gates the invite UI; the server |
| // enforces it regardless). Injected so the client needs no extra round trip. |
| page = strings.ReplaceAll(page, "__USER_INVITES__", boolStr(s.db.usersCanInvite())) |
| + page = strings.ReplaceAll(page, "__CSP_NONCE__", nonce) |
| w.Write([]byte(page)) |
| } |
| } |
diff --git a/security_headers_test.go b/security_headers_test.go| new file mode 100644 |
| index 0000000..3df3e5f |
| --- /dev/null |
| +++ b/security_headers_test.go |
| @@ -0,0 +1,71 @@ |
| +package main |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +var cspNonceRE = regexp.MustCompile(`'nonce-([A-Za-z0-9+/=]+)'`) |
| + |
| +func TestSecurityHeaders(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + |
| + // Static headers apply to every response, including non-page routes. |
| + resp, err := http.Get(ts.URL + "/api/version") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + for k, want := range map[string]string{ |
| + "X-Content-Type-Options": "nosniff", |
| + "X-Frame-Options": "DENY", |
| + "Referrer-Policy": "no-referrer", |
| + } { |
| + if got := resp.Header.Get(k); got != want { |
| + t.Errorf("%s = %q, want %q", k, got, want) |
| + } |
| + } |
| + |
| + // The chat page carries a nonce CSP. |
| + resp, err = http.Get(ts.URL + "/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + |
| + csp := resp.Header.Get("Content-Security-Policy") |
| + for _, want := range []string{ |
| + "default-src 'self'", "script-src 'self' 'nonce-", |
| + "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", |
| + } { |
| + if !strings.Contains(csp, want) { |
| + t.Errorf("CSP missing %q; got %q", want, csp) |
| + } |
| + } |
| + |
| + // The CSP nonce must match the one substituted into the page's inline scripts, |
| + // and no literal placeholder may remain. |
| + m := cspNonceRE.FindStringSubmatch(csp) |
| + if m == nil { |
| + t.Fatalf("no nonce in CSP: %q", csp) |
| + } |
| + nonce := m[1] |
| + if !strings.Contains(string(body), `<script nonce="`+nonce+`">`) { |
| + t.Errorf("inline <script> nonce doesn't match CSP nonce %q", nonce) |
| + } |
| + if strings.Contains(string(body), "__CSP_NONCE__") { |
| + t.Errorf("literal __CSP_NONCE__ placeholder left unsubstituted") |
| + } |
| + |
| + // Nonces are fresh per request. |
| + resp2, _ := http.Get(ts.URL + "/") |
| + csp2 := resp2.Header.Get("Content-Security-Policy") |
| + resp2.Body.Close() |
| + if m2 := cspNonceRE.FindStringSubmatch(csp2); m2 != nil && m2[1] == nonce { |
| + t.Errorf("nonce reused across requests: %q", nonce) |
| + } |
| +} |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index bcbd981..8054a7f 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -5,7 +5,7 @@ |
| <!-- Apply the saved theme before first paint (see web/static/ui.js — the canonical |
| list; this copy must stay in sync). A local preference, never sent to the server. --> |
| -<script> |
| +<script nonce="__CSP_NONCE__"> |
| (function () { |
| try { |
| var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
diff --git a/web/pages/index.html b/web/pages/index.html| index e72c077..5840efb 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -8,7 +8,7 @@ |
| <title>__GROUP_NAME__</title> |
| <!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the |
| document so CSS can adopt native conventions (e.g. drop web hand cursors). --> |
| -<script> |
| +<script nonce="__CSP_NONCE__"> |
| if (navigator.userAgent.includes("lchatMac")) |
| document.documentElement.classList.add("native"); |
| </script> |
| @@ -23,7 +23,7 @@ |
| default). The canonical list + defaults live in web/static/ui.js; this small |
| copy must stay in sync. The choice is a local preference only — it is never |
| sent to the server. --> |
| -<script> |
| +<script nonce="__CSP_NONCE__"> |
| (function () { |
| try { |
| var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
diff --git a/web/pages/join.html b/web/pages/join.html| index c07af80..07823c7 100644 |
| --- a/web/pages/join.html |
| +++ b/web/pages/join.html |
| @@ -4,7 +4,7 @@ |
| <title>__GROUP_NAME__</title> |
| <!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the |
| document so CSS can adopt native conventions (e.g. drop web hand cursors). --> |
| -<script> |
| +<script nonce="__CSP_NONCE__"> |
| if (navigator.userAgent.includes("lchatMac")) |
| document.documentElement.classList.add("native"); |
| </script> |
| @@ -19,7 +19,7 @@ |
| <!-- Apply the saved theme before first paint (see web/static/ui.js — the canonical |
| list; this copy must stay in sync). A local preference, never sent to the server. --> |
| -<script> |
| +<script nonce="__CSP_NONCE__"> |
| (function () { |
| try { |
| var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |