diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md index a3dd5dd..6d8b15e 100644 --- a/docs/backend/admin-spa.md +++ b/docs/backend/admin-spa.md @@ -15,7 +15,7 @@ cabana mounts the SPA under the admin prefix, `backend.uri` (`/backend` by defau - Any path under the prefix that is not a file and not under `api/` returns `index.html`, so the SPA's own routes work on reload. - Paths under `api/` that no API route matches return the admin API's JSON `not_found` error, never the SPA. - Hashed files under `assets/` are cached for a long time; `index.html` is never cached. -- Every response carries a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. +- Every response carries a restrictive Content-Security-Policy (`frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`: scripts only from the admin origin, no native form post to another origin, no framed content), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. The build is path-agnostic: `index.html` holds a placeholder (`boardwalk.BaseToken`) that `boardwalk.RewriteIndex` replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up. diff --git a/modules/boardwalk/README.md b/modules/boardwalk/README.md index 27f9124..c81004e 100644 --- a/modules/boardwalk/README.md +++ b/modules/boardwalk/README.md @@ -27,7 +27,7 @@ Use cases: - Falls back to `index.html` for client-side routes and directories; missing files with an extension get a plain 404. - Hands every request whose path under the prefix is `api` or starts with `api/` to a caller-supplied handler, so admin API misses stay JSON instead of returning the SPA. - Long-lived immutable caching for hashed files under `assets/`, `no-cache` for other files and `no-store` for `index.html`. -- Security headers on every response: a restrictive Content-Security-Policy, frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. +- Security headers on every response: a restrictive Content-Security-Policy (`frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`, `script-src 'self'`, `form-action 'self'`, `frame-src 'none'`), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`. - Explicit content types for scripts, styles, fonts, SVG and JSON, with a MIME lookup fallback. ## Usage @@ -55,7 +55,7 @@ mux.Handle("/backend/", spa) | `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. | | `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. | | `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. | -| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the `script-src 'self'` CSP, noindex) on a response. | +| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the CSP with `script-src 'self'`, `form-action 'self'` and `frame-src 'none'`, noindex) on a response. | ## Dependencies diff --git a/modules/boardwalk/boardwalk.go b/modules/boardwalk/boardwalk.go index 2add916..7697721 100644 --- a/modules/boardwalk/boardwalk.go +++ b/modules/boardwalk/boardwalk.go @@ -28,8 +28,11 @@ var distFS embed.FS const BaseToken = "__SUMMER_ADMIN_BASE__" // contentSecurityPolicy keeps the admin out of frames and allows scripts -// only from its own origin; the build contains no inline script. -const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'" +// only from its own origin; the build contains no inline script. Injected +// markup can neither post a native form off-origin (form-action 'self') nor +// frame fetched content (frame-src 'none'); a sandboxed srcdoc preview is not +// fetched, so it still renders. +const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'" var contentTypes = map[string]string{ ".js": "text/javascript; charset=utf-8", @@ -164,7 +167,8 @@ func ContentType(name string) string { // SetSecurityHeaders sets the admin response headers: nosniff, a same-origin // referrer policy, no framing, the admin Content-Security-Policy -// (script-src 'self') and noindex. +// (script-src 'self', form-action 'self', frame-src 'none': no native form +// post off-origin, no framed content) and noindex. func SetSecurityHeaders(h http.Header) { h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") diff --git a/modules/boardwalk/boardwalk_test.go b/modules/boardwalk/boardwalk_test.go index 3358ffa..eaaae5b 100644 --- a/modules/boardwalk/boardwalk_test.go +++ b/modules/boardwalk/boardwalk_test.go @@ -310,7 +310,7 @@ func TestSecurityHeadersOnEveryResponse(t *testing.T) { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", - "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'", + "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'", "X-Robots-Tag": "noindex, nofollow", } { if got := rec.Header().Get(header); got != want { @@ -482,7 +482,7 @@ func TestPhase101BoardwalkExports(t *testing.T) { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", - "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'", + "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'", "X-Robots-Tag": "noindex, nofollow", } if len(h) != len(want) {