fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)
- the two pinned jsDelivr scripts carry the per-request nonce next to their SRI hash; script-src holds only the nonce, no host source
This commit is contained in:
@@ -299,4 +299,4 @@ surf converts the document once at start-up: a document starting with `{` must b
|
||||
| `http.api_docs.path` | `/api-docs` | The page path; the JSON route is the same path plus `.json`. It must be an absolute URL path, and neither route may replace a plugin route. |
|
||||
| `http.api_docs.title` | `API documentation` | The page title. |
|
||||
|
||||
The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
|
||||
The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` whose only script source is a per-request nonce: scripts run only with the page's nonce. The two jsDelivr scripts carry it as well as their SRI hash and the inline start-up script carries it, so no other jsDelivr script can run, even if HTML were injected into the page. The page also sends `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
|
||||
|
||||
@@ -36,7 +36,7 @@ Use cases:
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET <storage.uploads.public_path_prefix>/{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`).
|
||||
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a nonce-based `Content-Security-Policy`.
|
||||
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a `Content-Security-Policy` whose only script source is a per-request nonce; every script tag, the two jsDelivr scripts included, carries it, so no other jsDelivr script can run.
|
||||
- Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies.
|
||||
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ const (
|
||||
swaggerUIBundleIntegrity = "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw"
|
||||
swaggerUIPreset = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js"
|
||||
swaggerUIPresetIntegrity = "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF"
|
||||
swaggerUICDN = "https://cdn.jsdelivr.net"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -63,8 +62,8 @@ var apiDocsPage = template.Must(template.New("apidocs").Parse(`<!DOCTYPE html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous"></script>
|
||||
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous"></script>
|
||||
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous" nonce="{{.Nonce}}"></script>
|
||||
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous" nonce="{{.Nonce}}"></script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
window.addEventListener('load', function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
@@ -185,8 +184,8 @@ func apiDocsJSON(raw []byte) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce for
|
||||
// its one inline script.
|
||||
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce on
|
||||
// every script tag, the two pinned assets and the inline start-up script.
|
||||
func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
nonceBytes := make([]byte, 18)
|
||||
@@ -204,7 +203,9 @@ func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"' "+swaggerUICDN+"; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
||||
// The nonce is the only script source: the two jsDelivr scripts
|
||||
// carry it next to their SRI hash, so no other jsDelivr script runs.
|
||||
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
||||
_, _ = w.Write(page.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +127,13 @@ func TestAPIDocsServesPinnedPage(t *testing.T) {
|
||||
t.Errorf("headers = %v", hd)
|
||||
}
|
||||
csp := hd.Get("Content-Security-Policy")
|
||||
m := regexp.MustCompile(`script-src 'nonce-([A-Za-z0-9_-]+)' https://cdn\.jsdelivr\.net;`).FindStringSubmatch(csp)
|
||||
m := regexp.MustCompile(`script-src 'nonce-([A-Za-z0-9_-]+)';`).FindStringSubmatch(csp)
|
||||
if m == nil {
|
||||
t.Fatalf("CSP %q", csp)
|
||||
}
|
||||
if strings.Contains(csp, "https:") {
|
||||
t.Errorf("CSP %q names a host source; script-src must hold only the nonce", csp)
|
||||
}
|
||||
for _, part := range []string{"object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(csp, part) {
|
||||
t.Errorf("CSP %q lacks %s", csp, part)
|
||||
@@ -140,6 +143,16 @@ func TestAPIDocsServesPinnedPage(t *testing.T) {
|
||||
if len(scripts) != 1 || scripts[0][1] != m[1] {
|
||||
t.Fatalf("inline script nonces %v, CSP nonce %q", scripts, m[1])
|
||||
}
|
||||
// Every script tag, the two pinned assets included, carries the nonce.
|
||||
tags := regexp.MustCompile(`<script[^>]*>`).FindAllString(page, -1)
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("script tags = %q, want 3", tags)
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !strings.Contains(tag, `nonce="`+m[1]+`"`) {
|
||||
t.Errorf("script tag %q lacks the CSP nonce", tag)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(page, `url: "/api-docs.json"`) || !strings.Contains(page, "validatorUrl: null") {
|
||||
t.Error("page does not load the local spec with the validator off")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user