Skip to content

fix(api): /api/docs/ is blank — restrictive CSP blocks Swagger UI assets #329

@cristim

Description

@cristim

Summary

The "API Docs" link in the header opens a blank page. Confirmed reproducible against the bare Lambda Function URL: https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/ returns the Swagger UI HTML (200 OK, 580 bytes) but the browser renders nothing.

Root cause (corrected)

internal/api/handler.go:222 (setSecurityHeaders) applies the same maximally-restrictive Content-Security-Policy to every response from the Lambda, including the inline Swagger UI HTML served by docsHandler:

Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

With default-src 'none' and no allow-listed sources, the browser refuses to:

  • load the Swagger UI bundle/CSS that serveDocsUI references (https://unpkg.com/swagger-ui-dist@5/...)
  • execute the inline <script>SwaggerUIBundle({...})</script> that bootstraps the UI
  • fetch(/api/docs/openapi.yaml) from inside the UI

Result: the HTML reaches the browser but nothing runs → blank page. The Swagger UI HTML body itself is correct (verified via curl) and /api/docs/openapi.yaml returns the full 63 KB spec with the right content-type.

This is a deployment-agnostic issue — there is no CDN/CloudFront in front of the Lambda Function URL; the CSP comes from the Lambda's own response headers.

What the prior body got wrong

The previous diagnosis assumed /docs/ was hitting a SPA bundle via CDN routing. Wrong — no CDN involved. The header-link path (/docs/ vs /api/docs/) is a separate, secondary concern: both paths reach docsHandler thanks to the prefix routes at router.go:234-235, and both produce the same blank page because of the CSP. The CSP is the primary bug.

Files

  • CSP source: internal/api/handler.go:222headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
  • Docs HTML: internal/api/handler_docs.go::serveDocsUI — references unpkg.com/swagger-ui-dist@5/* via CDN
  • Header link: frontend/src/index.html:27<a href="/docs/" target="_blank">
  • Test: frontend/src/__tests__/html.test.ts:595

Fix candidates

The cleanest path depends on whether we want to keep a CDN dependency or not. Two viable options:

A. Self-host Swagger UI assets, narrow the CSP per-route

  1. Vendor swagger-ui.css + swagger-ui-bundle.js from swagger-ui-dist@5 into the binary (//go:embed alongside openapi.yaml).
  2. Have docsHandler serve /api/docs/swagger-ui.css and /api/docs/swagger-ui-bundle.js from the embedded bytes (the prefix route already swallows the whole subtree).
  3. Update serveDocsUI HTML to point at those same-origin paths.
  4. In setSecurityHeaders, special-case the /docs and /api/docs paths to use a relaxed-but-still-tight CSP — e.g. default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'. 'unsafe-inline' is required for the bootstrap <script> block in serveDocsUI; alternatively, move the bootstrap to an external script and drop 'unsafe-inline'.

Pros: no external CDN dependency, smaller blast-radius CSP relaxation, works offline.

B. Keep unpkg CDN, allow-list it from the CSP for the docs path only

  1. In setSecurityHeaders, branch on path: for /docs and /api/docs, emit a CSP that adds https://unpkg.com to script-src / style-src, 'unsafe-inline' for the bootstrap, and 'self' for connect-src (the openapi.yaml fetch).
  2. All other API responses keep the current default-src 'none' policy.

Pros: smaller code change.
Cons: live dependency on unpkg.com (network, supply-chain).

Header-link path (secondary)

Once the CSP is fixed, the header link /docs/ should also be confirmed to work end-to-end. If the production routing genuinely sends the request to docsHandler (no CDN, so the same Lambda URL → router prefix match), both /docs/ and /api/docs/ will work. If a future deployment puts a CDN/proxy in front of the Lambda with /api/* → Lambda and /* → static SPA, the link should be updated to /api/docs/. Marking this as a follow-up after the CSP fix lands.

Acceptance criteria

  • /api/docs/ renders Swagger UI in production (no blank page).
  • /api/docs/openapi.yaml still returns the spec (unchanged).
  • Other API endpoints keep their restrictive CSP — no relaxation outside the docs path.
  • Test added in internal/api/handler_security_test.go (or sibling) covering the docs-path CSP exception.
  • If option A: the binary embeds the swagger-ui assets — no runtime fetch from unpkg.com on the docs page.
  • Header link verified working end-to-end after fix.

Reproduction

curl -sSI -X GET "https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/" | grep -i 'content-security-policy'
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

curl -sS "https://33pz7pombdqwu3bdlxp4lqxyra0bsriy.lambda-url.us-east-1.on.aws/api/docs/" | head
# returns the Swagger UI HTML, but the browser will not execute or load anything because of the CSP

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions