fix(web): vendor Swagger UI so /api/docs works offline (#693 part 2)#712
Merged
fix(web): vendor Swagger UI so /api/docs works offline (#693 part 2)#712
Conversation
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]>
aa7c33f to
929b771
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/docspage pullsswagger-ui-bundle.jsandswagger-ui.cssfromcdn.jsdelivr.neton every render, and the response HTML's favicon is hardcoded tofastapi.tiangolo.com. Same three failure modes as #706:/api/docsdoesn't render at all.integrity=hash, so a jsdelivr compromise served arbitrary code into the same origin as/api/....Note
Stacked on #706 (
fix/web-vendor-cdnjs) for thevendor/infra and the tightened CSP. After #706 merges I'll rebase--onto origin/mainperfeedback_stacked_pr_rebase_onto.mdand re-target the base.What changed
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 licensesswagger-ui-LICENSE— verbatim Swagger UI Apache-2.0swagger-init.js— see below/api/docsroute inweb/app.py(instead offastapi.openapi.docs.get_swagger_ui_html). Two CSP gaps land on the same page:<script>, which the locked-downscript-src 'self'(from fix(web): vendor cdnjs assets so the SPA works offline (#693 part 1) #706) blocks. Loading the bootstrap as an externalswagger-init.jskeeps the policy strict instead of growing back to'unsafe-inline'.https://fastapi.tiangolo.com/img/favicon.png, whichimg-src 'self' data:blocks. Reusing the SPA's ownfavicon.svgkeeps the page first-party end-to-end.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/docsif anyone asks.SwaggerUIStandalonePresetintentionally omitted inswagger-init.js— it lives in a separate ~250 KB file and only adds the topbar (URL input, branding). The bundle'spresets.apisrenders the operation list directly, which is all the embedded/api/docssurface needs.vendor/THIRD_PARTY_LICENSES.mdextended with two new rows (bundle.js, css) + a Swagger UI section that explains the webpack-transitive licensing. SHA-256 pinned.vendor/README.mdextended: 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→ cleanuv 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 passedmm webon isolated worktree, navigate to/api/docs:memtomem Web UI — Swagger UI,window.uiis the initialised Swagger UI instance, 84 API operations rendered, the#swagger-uidiv is mounted with the full Swagger UI tree./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 tocdn.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/docsHTML (perfeedback_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