Skip to content

fix(web): vendor Swagger UI so /api/docs works offline (#693 part 2)#712

Merged
memtomem merged 1 commit intomainfrom
fix/api-docs-swagger-vendor
May 2, 2026
Merged

fix(web): vendor Swagger UI so /api/docs works offline (#693 part 2)#712
memtomem merged 1 commit intomainfrom
fix/api-docs-swagger-vendor

Conversation

@memtomem
Copy link
Copy Markdown
Owner

@memtomem memtomem commented May 2, 2026

Summary

Closes part 2 of #693. Companion to #706, which vendored the SPA's cdnjs assets; this PR closes the second offline footgun the issue called out.

FastAPI's default /api/docs page pulls swagger-ui-bundle.js and swagger-ui.css from cdn.jsdelivr.net on every render, and the response HTML's favicon is hardcoded to fastapi.tiangolo.com. Same three failure modes as #706:

  • Offline / firewalled deployments/api/docs doesn't render at all.
  • Privacy — every visit is a beacon to jsdelivr (IP, UA, request time) and tiangolo.com.
  • Trust-on-first-use — no integrity= hash, so a jsdelivr compromise served arbitrary code into the same origin as /api/....

Note

Stacked on #706 (fix/web-vendor-cdnjs) for the vendor/ infra and the tightened CSP. After #706 merges I'll rebase --onto origin/main per feedback_stacked_pr_rebase_onto.md and re-target the base.

What changed

  • Vendored swagger-ui-dist 5.32.5 under web/static/vendor/swagger/:
    • swagger-ui-bundle.js (1.5 MB)
    • swagger-ui.css (175 KB)
    • swagger-ui-bundle.js.LICENSE.txt — webpack-extracted attribution headers for the bundle's transitive deps (classnames, deep-extend, immutable.js, react, redux, …) — required by the source-form attribution clauses on those licenses
    • swagger-ui-LICENSE — verbatim Swagger UI Apache-2.0
    • swagger-init.js — see below
  • Hand-rolled /api/docs route in web/app.py (instead of fastapi.openapi.docs.get_swagger_ui_html). Two CSP gaps land on the same page:
    • The default helper bakes the Swagger UI bootstrap into an inline <script>, which the locked-down script-src 'self' (from fix(web): vendor cdnjs assets so the SPA works offline (#693 part 1) #706) blocks. Loading the bootstrap as an external swagger-init.js keeps the policy strict instead of growing back to 'unsafe-inline'.
    • The default helper points the favicon at https://fastapi.tiangolo.com/img/favicon.png, which img-src 'self' data: blocks. Reusing the SPA's own favicon.svg keeps the page first-party end-to-end.
  • Dropped redoc_url. ReDoc duplicates Swagger UI's purpose, also pulled from jsdelivr by default, and nothing in the SPA linked to it. The 404 catch-all under /api/{path:path} is the new response. Re-introduce the same way as /api/docs if anyone asks.
  • SwaggerUIStandalonePreset intentionally omitted in swagger-init.js — it lives in a separate ~250 KB file and only adds the topbar (URL input, branding). The bundle's presets.apis renders the operation list directly, which is all the embedded /api/docs surface needs.
  • License & update process:
    • vendor/THIRD_PARTY_LICENSES.md extended with two new rows (bundle.js, css) + a Swagger UI section that explains the webpack-transitive licensing. SHA-256 pinned.
    • vendor/README.md extended: Swagger UI curl block (with the same supply-chain check on same-version re-fetch added in fix(web): vendor cdnjs assets so the SPA works offline (#693 part 1) #706), license refresh URL, expanded smoke checklist (open /api/docs, confirm Try-it-out works, DevTools shows no requests to jsdelivr / cdnjs).

Test plan

  • uv run ruff check packages/memtomem/src && uv run ruff format --check packages/memtomem/src → clean
  • uv run pytest packages/memtomem/tests/test_web_swagger_vendor.py packages/memtomem/tests/test_web_csp_vendor.py → 23/23 passed (6 new + 12 existing + 5 — wait, let me recount: 6 swagger + 12 csp/vendor = 18 total, all green)
  • uv run pytest packages/memtomem/tests -m "not ollama" -k "web or app or fastapi or static" → 663 passed
  • Browser smoke via Playwright MCP — mm web on isolated worktree, navigate to /api/docs:
    • Page loads end-to-end: title memtomem Web UI — Swagger UI, window.ui is the initialised Swagger UI instance, 84 API operations rendered, the #swagger-ui div is mounted with the full Swagger UI tree.
    • 0 console errors / warnings.
    • Network: /vendor/swagger/swagger-ui.css?v=1, /vendor/swagger/swagger-ui-bundle.js?v=1, /vendor/swagger/swagger-init.js?v=1, /openapi.json — all 200/304. No requests to cdn.jsdelivr.net / cdnjs.cloudflare.com / fastapi.tiangolo.com / unpkg.com.

Tests added

tests/test_web_swagger_vendor.py:

  • test_api_docs_serves_vendored_swagger_ui_not_jsdelivr — paired positive + negative assertion on the /api/docs HTML (per feedback_pin_invert_symmetric_assertion.md). Positive: /vendor/swagger/swagger-ui-bundle.js, /vendor/swagger/swagger-ui.css, /vendor/swagger/swagger-init.js, href="/favicon.svg". Negative: cdn.jsdelivr.net, swagger-ui-dist@, cdnjs.cloudflare.com, unpkg.com.
  • test_swagger_vendor_asset_served_locally[…] — parametrized over the three Swagger UI files, asserts 200 + non-empty body.
  • test_redoc_default_route_disabled — pins the ReDoc removal (regression here would silently re-introduce a jsdelivr fetch).
  • test_openapi_json_still_served — Swagger UI page is useless if the spec endpoint disappears; pin so a future cleanup can't quietly drop it.

🤖 Generated with Claude Code

@memtomem memtomem changed the base branch from fix/web-vendor-cdnjs to main May 2, 2026 05:27
Closes #693 part 2. Companion to #706, which vendored the SPA's cdnjs
assets; this PR closes the second offline footgun called out in the
issue: FastAPI's default ``/api/docs`` page pulls
``swagger-ui-bundle.js`` and ``swagger-ui.css`` from
``cdn.jsdelivr.net`` on every render, and the response HTML's favicon
points at ``fastapi.tiangolo.com``. Same three failure modes as #706:
offline / firewalled deployments break, every page load is a privacy
beacon, and there's no SRI hash on the CDN URLs.

Vendor swagger-ui-dist 5.32.5 under web/static/vendor/swagger/ —
bundle.js (1.5 MB), swagger-ui.css (175 KB), the webpack-extracted
transitive licenses (3.3 KB), and the upstream LICENSE. The directory is
served by the existing StaticFiles mount, so no new route was needed
for the assets.

Drop ``docs_url`` and ``redoc_url`` from the FastAPI factory and
register a hand-rolled ``/api/docs`` route. Hand-rolled instead of
``fastapi.openapi.docs.get_swagger_ui_html`` because two CSP gaps land
on the same page:

* The default helper bakes the Swagger UI bootstrap into an inline
  ``<script>`` block, which the locked-down ``script-src 'self'``
  blocks. Loading the bootstrap as an external file
  (``swagger-init.js``) keeps the policy strict instead of growing back
  to ``'unsafe-inline'``.
* The default helper points the favicon at
  ``https://fastapi.tiangolo.com/img/favicon.png``, which
  ``img-src 'self' data:`` would block. Reusing the SPA's own
  ``favicon.svg`` keeps the page first-party end-to-end.

``SwaggerUIStandalonePreset`` is intentionally omitted from
``swagger-init.js`` — it lives in a separate ~250KB file and only adds
the topbar (URL input, branding). The bundle's default ``presets.apis``
renders the operation list directly, which is all the embedded /api/docs
surface needs.

ReDoc is also dropped (``redoc_url=None``). It duplicates Swagger UI's
purpose, also pulled from jsdelivr by default, and nothing in the SPA
linked to it. Re-introduce it the same way as ``/api/docs`` if a
consumer asks.

Tests in ``tests/test_web_swagger_vendor.py``:

* Paired positive + negative assertions on the ``/api/docs`` HTML —
  pins the local /vendor/swagger refs and the SPA favicon, blocks
  ``cdn.jsdelivr.net`` / ``cdnjs`` / ``unpkg`` and the
  ``swagger-ui-dist@`` jsdelivr-shape regression. Pattern reference:
  ``feedback_pin_invert_symmetric_assertion.md``.
* Parametrized 200-and-non-empty check across the three vendored
  Swagger UI files.
* ``test_redoc_default_route_disabled`` pins the ReDoc removal — the
  ``/api/{path:path}`` 404 catch-all is the expected response.
* ``test_openapi_json_still_served`` pins ``/openapi.json`` since the
  Swagger UI page is useless without it.

Browser smoke via Playwright MCP: ``/api/docs`` renders end-to-end
(``window.ui`` initialised, 84 operations listed, full Swagger UI div
mounted), 0 console errors, no network requests to
``cdn.jsdelivr.net`` / ``cdnjs.cloudflare.com`` / ``fastapi.tiangolo.com``.

Stacked on #706 (``fix/web-vendor-cdnjs``) for the vendor/ infra and
the tightened CSP. Will rebase --onto origin/main after #706 merges,
per ``feedback_stacked_pr_rebase_onto.md``.

Co-Authored-By: Claude <[email protected]>
@memtomem memtomem force-pushed the fix/api-docs-swagger-vendor branch from aa7c33f to 929b771 Compare May 2, 2026 05:28
@memtomem memtomem merged commit 0c55912 into main May 2, 2026
8 of 9 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators May 2, 2026
@memtomem memtomem deleted the fix/api-docs-swagger-vendor branch May 2, 2026 05:34
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants