fix(boardwalk): add form-action and frame-src to the admin CSP

- form-action 'self': injected markup cannot post a native form off-origin
- frame-src 'none': no framed content; a sandboxed srcdoc preview is not
  fetched and still renders
- header tests, README and admin-spa docs name the directives
This commit is contained in:
2026-10-12 20:41:21 +02:00
parent 71bd614a95
commit 2343788624
4 changed files with 12 additions and 8 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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) {