eleven / ocho Public

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>
Paul Campbell 209504b8495362ef797188c07a6f5c6835f0c680 parent a3d8e53
5 files changed, +121 −6
  • main.go +45 −1
  • security_headers_test.go +71 −0
  • web/pages/admin.html +1 −1
  • web/pages/index.html +2 −2
  • web/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"];