Skip to content

helmet()'s default Content-Security-Policy breaks the Swagger UI at /api-docs — no CSP exception configured for the docs route #129

Description

@prodbycorne

Overview

helmet() is mounted globally, applying its default Content-Security-Policy to every response — including the Swagger UI docs page mounted at /api-docs in development — with no CSP exception configured for that route. swagger-ui-express (the package serving that page) renders by injecting inline <script>/<style> content, which helmet's default CSP directives (script-src 'self', style-src 'self', no 'unsafe-inline') block by design.

// src/index.js
app.use(requestIdMiddleware);
app.use(helmet());
app.use(buildCorsMiddleware(config.corsAllowedOrigins));
app.use(express.json({ limit: config.airdrops.jsonMaxBytes }));
...
app.use('/api-docs', globalApiLimit);
app.use('/api-docs', apiDocsRouter);
// src/routes/apiDocs.js
if (config.nodeEnv === 'development') {
  router.use('/', swaggerUi.serve, swaggerUi.setup(openApiDocument, {
    explorer: true,
    customSiteTitle: 'SmartDrop API Docs',
  }));
} else {
  router.get('/', (_req, res) => { res.redirect('/api-docs/openapi.yaml'); });
}

helmet() is called with no options — meaning helmet 8.x's default contentSecurityPolicy middleware is active, applying its default directive set (which does not permit 'unsafe-inline' for scripts or styles) to every response the app sends, with no per-route override anywhere in src/index.js or src/routes/apiDocs.js disabling or relaxing it for the /api-docs path specifically. swagger-ui-express's swaggerUi.setup(...) middleware serves an HTML page that bootstraps the Swagger UI React application via inline <script> tags (and the UI itself injects further inline styles at runtime) — content a strict default CSP with no 'unsafe-inline' allowance for script-src/style-src blocks browsers from executing/applying, per browsers' standard CSP enforcement behavior. The practical result: visiting /api-docs in a browser that respects CSP would load the HTML shell successfully (helmet doesn't block the page from loading, only specific resource types it disallows) but the interactive Swagger UI itself would fail to initialize — console CSP violation errors, a blank or non-functional docs explorer — defeating the purpose of swaggerUi.explorer: true and the whole /api-docs feature, in the one environment (development) where it's actually served as an interactive UI rather than redirected to the raw YAML.

This is a well-known, commonly-documented interaction (searching "helmet swagger-ui-express CSP" surfaces this exact gotcha broadly across the ecosystem) — the standard fix is either disabling contentSecurityPolicy specifically for the docs route, or explicitly configuring script-src/style-src directives to permit what swagger-ui-express needs (ideally via nonces rather than a blanket 'unsafe-inline', to avoid weakening CSP protection for the rest of the app).

Requirements

  • Configure a CSP exception for the /api-docs route specifically — either a separate helmet({ contentSecurityPolicy: false }) (or an equivalently scoped override) applied only to that router, or explicit directives permitting what swagger-ui-express needs, applied only there — rather than weakening the global CSP for the entire application.
  • Prefer the narrowest fix that restores Swagger UI functionality without broadly disabling CSP protection app-wide; if 'unsafe-inline' is unavoidable for swagger-ui-express's bundled assets, scope it to the /api-docs path only.
  • Verify manually (or via a headless-browser test) that the docs page actually renders and is interactive after the fix, not just that the HTTP response succeeds — a 200 status alone would not have caught this bug, since the failure is purely in browser-side CSP enforcement of the response headers, not in the HTTP response itself.

Acceptance Criteria

  • /api-docs in NODE_ENV=development renders a fully interactive Swagger UI in a real (or headless) browser with no CSP violation errors in the console.
  • The rest of the application's routes retain helmet's default (or an equivalently strict) CSP — the fix is scoped to /api-docs only, not a global CSP relaxation.
  • A test (ideally using a headless browser like Puppeteer/Playwright if available in this project's test tooling, or at minimum an assertion on the actual Content-Security-Policy response header value for /api-docs versus other routes) verifies the scoped exception is in place.
  • test/api-docs.test.js is extended to cover this, since its current coverage (per the file's existence) evidently doesn't catch a CSP-only failure mode (an HTTP-level test asserting 200 and a body containing expected HTML would pass today even though the page doesn't actually work in a real browser).

Additional Notes

More precise references

  • src/index.js: confirmed app.use(helmet()); is called with no arguments (default configuration) and is mounted before any router, applying to all subsequent responses including /api-docs.
  • src/index.js: confirmed app.use('/api-docs', globalApiLimit); app.use('/api-docs', apiDocsRouter); — no helmet override, no CSP-specific middleware, anywhere between the global helmet() call and the docs router.
  • src/routes/apiDocs.js:15-19: confirmed swaggerUi.serve, swaggerUi.setup(openApiDocument, { explorer: true, ... }) is only mounted when config.nodeEnv === 'development' — the production path (else branch, lines 20-23) just redirects to the raw openapi.yaml file and would not be affected by this bug at all, since it never serves the Swagger UI HTML/JS.
  • helmet ^8.2.0 (per package.json): confirmed current helmet major versions enable contentSecurityPolicy by default as part of the bundled middleware set, with a default directive list that does not include 'unsafe-inline' for script-src/style-src.

Additional edge cases

  • Since this only manifests in NODE_ENV=development (production redirects instead of serving the interactive UI), the practical blast radius is limited to local development and any shared "dev"/staging environment where /api-docs is used interactively — but that's presumably exactly the use case the explorer: true interactive Swagger UI was added for in the first place (per closed issue Add OpenAPI 3.0 specification for all API endpoints #26, "Add OpenAPI 3.0 specification for all API endpoints"), so it's a real, currently-broken developer-experience feature, not a cosmetic edge case.
  • Worth double-checking whether helmet's other default protections (e.g. X-Frame-Options, Cross-Origin-Embedder-Policy) also interfere with Swagger UI's iframe/resource-loading behavior in some configurations — the CSP issue is the most certain and most commonly documented one, but a full manual/browser check during the fix should look for any other console warnings, not just CSP ones.

Test/reproduction plan

  • Start the app with NODE_ENV=development, request /api-docs with a real or headless browser, and check the browser console for CSP violation errors (Refused to execute inline script because it violates the following Content Security Policy directive...) — this would not be visible from a plain supertest/HTTP-level test, only from something that actually parses and enforces the returned headers against the returned body, which is why this has likely gone unnoticed by test/api-docs.test.js if that test only asserts HTTP-level status/body-contains-string assertions.
  • After the fix, confirm the Content-Security-Policy response header for GET /api-docs differs from (is more permissive, in a scoped way) than the header for e.g. GET /api/v1/prices/XLM, proving the exception is scoped rather than global.

Cross-references

  • Add OpenAPI 3.0 specification for all API endpoints #26 (closed) — "Add OpenAPI 3.0 specification for all API endpoints," the feature whose interactive documentation UI is broken by this issue.
  • Add CORS policy hardening and configurable allowed origins #32 (closed) — "Add CORS policy hardening and configurable allowed origins" — different middleware, same general "security headers/policy middleware interacting with a specific route's needs" neighborhood; worth a shared glance during review in case any similar route-specific exception is also needed for CORS on /api-docs, though nothing found during this review suggests CORS is currently broken there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingdocumentationImprovements or additions to documentationvery hardExtremely hard — deep expertise, careful design, and significant time required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions