Add a try-it API page, and stop the docs page reaching for the internet

/api/docs renders the spec with Redoc, which is read-only by design. The thing
people mean when they ask for Swagger is firing a request from the page, so
Swagger UI now sits beside it at /api/docs/try, reading the same spec - one
contract, two renderers.

It is OFF outside development and 404s rather than rendering a disabled page: a
production box holds live fleet data, and a docs page that can POST to it is a
different object from a docs page that cannot.

Vendored, not linked. swagger-ui-bundle.min.js and swagger-ui.css are served
from staticdocs/ beside the existing Redoc bundle, because production has no
internet. validatorUrl is null: left unset, Swagger UI hands the spec to the
public swagger validator to render a badge, which here is a request that cannot
succeed and elsewhere would ship the internal API surface off the box.

THE VENDORED REDOC BUNDLE WAS ALREADY REACHING THE INTERNET, found while
proving this page does not. redoc.standalone.js fetched
cdn.redoc.ly/redoc/logo-mini.svg for the "API docs by Redocly" mark, so every
load of /api/docs on the air-gapped server has been waiting on a request that
cannot complete. Replaced with an inline transparent GIF.

Three gate holes closed, each one a dodge that worked:

- the page is built by interpolating url_for output into a JS string literal,
  unescaped. SCRIPT_NAME is proxy-controlled and ProxyFix honours
  X-Forwarded-Prefix, so a mount path carrying a quote closed the literal and
  executed what followed. The spec URL is now json.dumps'd and the rest
  HTML-escaped; the injected quote renders as \" and is inert
- the "no internet" assertion on the page scanned for https?://, so
  <script src="//cdn.jsdelivr.net/..."> - the form a copy-paste from any CDN's
  own documentation produces - matched nothing and passed
- the same assertion on the vendored files was a five-host CDN allowlist. An
  upstream bump to any other host walked through it, and the one real leak in
  that directory was on no list. It now matches the SHAPES a browser fetches -
  @import, css url(), src/href, @font-face - whatever the host, ignoring the
  data: URIs this CSS is full of

And the Redoc page's link to the try-it view is built with url_for, which raises
BuildError when those routes are not mounted - every production box. Made
unconditional it would 500 the docs page in exactly the deployment nobody tests
locally, and no test caught that. One does now.
This commit is contained in:
cproudlock
2026-09-18 20:18:23 -04:00
parent 5aa748cab5
commit 68567d9a27
8 changed files with 613 additions and 3 deletions

View File

@@ -0,0 +1,216 @@
"""The Swagger UI "try it" page at /api/docs/try.
Two things are being proved, and they are the two that would hurt on the
production box:
1. It is OFF unless a site turns it on, and off means the URL does not exist.
The page fires real requests at this server with the browser's own session,
so a disabled deployment must not serve it, advertise it, or confirm it is
a thing by answering anything other than 404.
2. Nothing it loads comes from the internet. Production is air-gapped: a CDN
reference, a webfont, or a source map pointing at a public host is not a
slow page, it is a page that never renders and a request that hangs.
These build their own Flask app around the docs blueprint rather than using the
session app from conftest. The blueprint touches no database, and a second
create_app() would rebind the SQLAlchemy extension and break whatever module
runs next (see the note on the gated_app fixture in test_public_read_surface).
It also lets one test see the flag on and the next see it off, which is the
whole behaviour under test.
"""
import os
import re
import pytest
from flask import Flask
from shopdb.core.api.docs import docs_bp
# Everything the try-it page is allowed to load, and the files behind them.
_CSS_PATH = '/api/docs/swagger-ui.css'
_JS_PATH = '/api/docs/swagger-ui-bundle.min.js'
_STATICDOCS = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'shopdb', 'core', 'api', 'staticdocs')
_VENDORED = ('swagger-ui.css', 'swagger-ui-bundle.min.js')
def _docs_app(tryit_enabled):
"""A bare app carrying only the docs blueprint, mounted where the real app
mounts it (register_blueprints uses /api/<name>)."""
application = Flask(__name__)
application.config['API_DOCS_TRYIT_ENABLED'] = tryit_enabled
application.register_blueprint(docs_bp, url_prefix='/api/docs')
return application
@pytest.fixture
def enabled_client():
return _docs_app(True).test_client()
@pytest.fixture
def disabled_client():
return _docs_app(False).test_client()
def test_enabled_page_renders_swagger_over_the_published_spec(enabled_client):
response = enabled_client.get('/api/docs/try')
assert response.status_code == 200
page = response.get_data(as_text=True)
assert 'SwaggerUIBundle(' in page
assert 'id="swagger-ui"' in page
# One spec, two renderers: the try-it page must read the same document the
# Redoc page serves, not a second copy that could drift from it.
assert '/api/docs/openapi.json' in page
def test_the_page_references_no_other_origin_at_all(enabled_client):
"""Absolute AND protocol-relative.
The first version of this scanned for https?://, so
<script src="//cdn.jsdelivr.net/npm/x.js"> - the form a copy-paste from any
CDN's own docs produces - matched nothing and passed.
"""
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
offenders = re.findall(r'(?:src|href)\s*=\s*[\'"]((?:https?:)?//[^\'"]+)', page)
assert not offenders, 'the try-it page loads from another origin: %s' % offenders
def test_the_redoc_page_survives_try_it_being_unmounted(app):
"""The banner linking to the try-it page is built with url_for, and url_for
raises BuildError when those routes are not registered - which is every
production box. An unconditional link therefore 500s /api/docs/ in exactly
the deployment nobody tests locally.
"""
# The testing config is the unmounted case, and it is the one available
# here: ProductionConfig refuses to build without a real SECRET_KEY, which
# is correct of it and makes it useless as a fixture.
page = app.test_client().get('/api/docs/')
assert page.status_code == 200, page.get_data(as_text=True)[:200]
body = page.get_data(as_text=True)
assert 'redoc' in body.lower()
assert '/api/docs/try' not in body
def test_enabled_page_loads_only_the_vendored_assets(enabled_client):
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
assert 'href="%s"' % _CSS_PATH in page
assert 'src="%s"' % _JS_PATH in page
for path, expected_type in ((_CSS_PATH, 'text/css'),
(_JS_PATH, 'application/javascript')):
asset = enabled_client.get(path)
assert asset.status_code == 200, path
assert expected_type in asset.headers['Content-Type']
assert asset.headers.get('Content-Length', '0') != '0'
def test_the_page_names_no_external_host(enabled_client):
"""Every URL on the page has to be same-origin. On an air-gapped box an
absolute http(s) URL is a request that hangs until it times out."""
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
external = re.findall(r'https?://[^\s"\'<>)]+', page)
assert not external, 'try-it page references external hosts: %s' % external
def test_the_spec_validator_is_turned_off(enabled_client):
"""Swagger UI's one outbound default: unset, it posts the spec to a public
validator to draw a badge. That is a hung request here, and it would be an
upload of the internal API surface anywhere it worked."""
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
assert re.search(r'validatorUrl:\s*null', page), (
'validatorUrl is not disabled; Swagger UI will call the public '
'validator')
def test_disabled_deployment_has_no_such_route(disabled_client):
"""404, not a rendered page explaining that it is disabled. The route is
never bound, so the URL confirms nothing."""
for path in ('/api/docs/try', '/api/docs/try/', _CSS_PATH, _JS_PATH):
assert disabled_client.get(path).status_code == 404, path
def test_the_redoc_page_advertises_try_it_only_when_it_exists(
enabled_client, disabled_client):
enabled_page = enabled_client.get('/api/docs/').get_data(as_text=True)
assert '/api/docs/try' in enabled_page
disabled_page = disabled_client.get('/api/docs/').get_data(as_text=True)
assert '/api/docs/try' not in disabled_page
# The word too, not just the href: a note about a view that 404s is worse
# than no note.
assert 'try-it' not in disabled_page
def test_every_url_follows_the_mount_path(enabled_client):
"""Prod runs under /shopdb. url_for has to carry the prefix onto the spec
and both assets, or the page loads nothing and can validate nothing."""
page = enabled_client.get(
'/api/docs/try',
environ_overrides={'SCRIPT_NAME': '/shopdb'}).get_data(as_text=True)
assert '/shopdb/api/docs/openapi.json' in page
assert '/shopdb%s' % _CSS_PATH in page
assert '/shopdb%s' % _JS_PATH in page
# No unprefixed twin left behind by a hardcoded path.
assert 'href="%s"' % _CSS_PATH not in page
assert 'src="%s"' % _JS_PATH not in page
redoc_page = enabled_client.get(
'/api/docs/',
environ_overrides={'SCRIPT_NAME': '/shopdb'}).get_data(as_text=True)
assert '/shopdb/api/docs/try' in redoc_page
@pytest.mark.parametrize('filename', _VENDORED)
def test_a_vendored_asset_fetches_nothing_from_the_internet(filename):
"""The assets themselves, not just the page.
Scoped to what a BROWSER would actually request. The swagger-ui bundle
carries around a hundred http(s) strings that are prose - RFC and
json-schema citations, licence URLs, the example.com host in the spec's own
documentation - and none of them is fetched, so a blanket "no http in this
file" assertion would be a lie that fails on the next upstream bump. What
would really produce a request is a CDN or webfont reference, a stylesheet
url() pointing off-box, or a source map: hence these.
"""
body = open(os.path.join(_STATICDOCS, filename),
encoding='utf-8', errors='replace').read()
# NOT a host allowlist. The first version of this named five CDNs, so an
# upstream bump to esm.sh or cdn.skypack.dev walked straight through it, and
# the one real leak in this directory was cdn.redoc.ly - which was on no
# list. Match the SHAPES a browser fetches instead, whatever the host:
# a stylesheet import, a css url(), a script/link/img source.
fetching = [
(r'@import\s+(?:url\()?[\'"]?(?:https?:)?//', 'an @import from another origin'),
(r'url\(\s*[\'"]?(?:https?:)?//', 'a css url() on another origin'),
(r'(?:src|href)\s*=\s*[\'"](?:https?:)?//', 'an element loaded from another origin'),
(r'@font-face', 'a webfont, which is a fetch unless it is a data: URI'),
]
for pattern, what in fetching:
for match in re.finditer(pattern, body):
window = body[max(0, match.start() - 60):match.start() + 120]
# data: URIs are inline, not fetched, and this CSS is full of them.
if 'data:' in window[:len(window) // 2 + 40]:
continue
raise AssertionError(
'%s carries %s: ...%s...' % (filename, what, window.strip()[:140]))
# A map reference is a request too. The upstream .map files are not
# vendored (they are tens of megabytes and only devtools reads them), so
# any surviving pointer is a guaranteed 404 on a box that cannot reach the
# internet to try elsewhere.
assert 'sourceMappingURL' not in body, (
'%s still points at a source map that is not vendored' % filename)
# Stylesheet resources: swagger-ui inlines its images as data: URIs, and
# anything else would be an off-box fetch on first paint. CSS only - in the
# minified bundle "url(" is a function call, not a resource.
if filename.endswith('.css'):
for match in re.findall(r'url\(\s*["\']?([^"\')]+)', body):
assert match.startswith('data:'), (
'%s loads %s rather than an inline data: URI'
% (filename, match))