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:
216
tests/test_core/test_api_docs_tryit.py
Normal file
216
tests/test_core/test_api_docs_tryit.py
Normal 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))
|
||||
Reference in New Issue
Block a user