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

7
.gitattributes vendored
View File

@@ -10,3 +10,10 @@ scripts/check-naming-and-style.sh text eol=lf
# nowhere on the error path - which is the path that matters in a promote
# script.
*.cmd text eol=crlf
# Vendored API-docs renderers (Redoc, Swagger UI). Minified upstream builds of
# code nobody here wrote: one line, over a megabyte each. -diff keeps a textual
# diff of that line out of every review, and linguist-vendored keeps them out of
# the language breakdown, which they would otherwise dominate.
shopdb/core/api/staticdocs/*.js linguist-vendored -diff
shopdb/core/api/staticdocs/*.css linguist-vendored -diff

View File

@@ -185,6 +185,22 @@ class Config:
AUTH_RATELIMIT_WINDOW_SECONDS = int(
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
# Swagger UI "try it" page at /api/docs/try, beside the read-only Redoc
# page at /api/docs. One spec, two renderers.
#
# Off unless a site turns it on, and that is the point rather than caution:
# Swagger UI's Execute button fires a REAL request at THIS server from the
# operator's browser, carrying whatever session that browser already holds.
# On a production box that is a one-click way to POST against live fleet
# data from a page anyone can open. Redoc, which cannot send anything, stays
# the default and is always on.
#
# When off the routes are never bound at all, so /api/docs/try is Flask's
# own 404 and the Redoc page does not mention it. See
# shopdb/core/api/docs.py.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'false').lower() == 'true'
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
@@ -214,6 +230,12 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# On in development: firing the request is the whole reason a developer
# opens the API docs. Still read from the environment, so a shared dev
# instance can turn it back off without editing this file.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'true').lower() == 'true'
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask',
@@ -234,6 +256,16 @@ class TestingConfig(Config):
# xdist distribution, so the failure moves around. The tests that are about
# the limiter turn it on themselves.
ENDPOINTS_ENROLL_RATELIMIT_ENABLED = False
# Off, and stated here rather than inherited, because a test suite that
# silently ran with it on would be testing a configuration no site ships.
# The session-wide app in tests/conftest.py is what
# tests/test_core/test_public_read_surface.py sweeps for ungated GET
# routes, and the try-it page plus its two vendored assets are
# deliberately unauthenticated. Keeping the default here means that sweep
# keeps describing what production actually exposes; the tests that ARE
# about the page build their own app with the flag on
# (tests/test_core/test_api_docs_tryit.py).
API_DOCS_TRYIT_ENABLED = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False},

View File

@@ -4,11 +4,21 @@ Served at /api/docs (relative to the mount). The Redoc bundle is vendored in
staticdocs/ so this works fully offline on the air-gapped prod box - no CDN.
The spec is docs/openapi.json in the repo (regenerate with
scripts/gen_openapi.py after API changes).
Two renderers over ONE spec. Redoc is read-only by design and is the default,
because what is published is a contract. Swagger UI at /api/docs/try adds the
one thing Redoc will not do: fire a real request from the page, against this
server, with whatever session the browser is already carrying. That is its
whole value and the whole reason it is off unless a site turns it on - see
API_DOCS_TRYIT_ENABLED in shopdb/config.py.
"""
import json
import os
from flask import Blueprint, Response, send_file, url_for
from markupsafe import escape
from flask import Blueprint, Response, current_app, send_file, url_for
docs_bp = Blueprint('docs', __name__)
@@ -19,21 +29,71 @@ _SPEC = os.path.join(_REPO_ROOT, 'docs', 'openapi.json')
_LLMS = os.path.join(_REPO_ROOT, 'docs', 'llms.txt')
_REDOC = os.path.join(_HERE, 'staticdocs', 'redoc.standalone.js')
# Vendored from swagger-ui-dist 5.33.0. TWO files out of the ~30 in that dist,
# because those are the two the page below actually loads:
# swagger-ui.css - the stylesheet. Its images are inline data: URIs,
# so it pulls nothing else. The trailing
# sourceMappingURL comment was stripped: the .map
# is not vendored, and left in place it turns every
# devtools open into a 404 on a box with no
# internet to fall back on.
# swagger-ui-bundle.min.js - the renderer plus the core presets, self-
# contained. NOT vendored beside it:
# swagger-ui-standalone-preset.js (the topbar with
# the "load any spec URL" box - this page serves
# one spec on purpose), the ES bundles (module
# builds of the same code), the .map files (~10 MB
# of source maps that only devtools reads),
# oauth2-redirect.html (no OAuth flow here), and
# the favicons.
# The upstream name is swagger-ui-bundle.js; the vendored copy carries .min.js,
# which is what it is - a minified single-line build. That suffix is also what
# scripts/check-naming-and-style.sh already filters vendored bundles by, so the
# style gate does not report thousands of hits in code nobody here wrote.
# swagger-ui.LICENSE.txt is the Apache-2.0 text that ships with the dist. It is
# never served; it is here because redistributing the bundle requires it.
_SWAGGER_CSS = os.path.join(_HERE, 'staticdocs', 'swagger-ui.css')
_SWAGGER_JS = os.path.join(_HERE, 'staticdocs', 'swagger-ui-bundle.min.js')
_TRYIT_ENDPOINT = 'docs.swagger_tryit'
def _tryit_is_mounted():
"""True when the try-it page exists on THIS app.
Reads the url map rather than the config flag on purpose: the routes are
bound once, at blueprint registration, so a config value changed after boot
would have the Redoc page advertise a link that answers 404.
"""
return _TRYIT_ENDPOINT in current_app.view_functions
@docs_bp.route('/', strict_slashes=False)
def docs_index():
"""Redoc page. url_for keeps the asset URLs correct under any mount."""
spec_url = url_for('docs.openapi_spec')
redoc_url = url_for('docs.redoc_js')
# Mentioned only when it is actually there. An unconditional note would be
# an advert for a 404 on every box that ships the shipped default.
tryit_note = ''
if _tryit_is_mounted():
tryit_note = (
'<p style="font:14px/1.4 system-ui,sans-serif;margin:0;'
'padding:10px 16px;background:#eef2f7;border-bottom:1px solid #cbd5e1">'
'This page is read-only. To send a real request against this '
'server, use <a href="%s">the try-it view</a>.</p>'
% url_for(_TRYIT_ENDPOINT)
)
page = (
'<!doctype html><html><head>'
'<title>ShopDB API</title>'
'<meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>body{margin:0;padding:0}</style></head><body>'
'%s'
'<redoc spec-url="%s"></redoc>'
'<script src="%s"></script>'
'</body></html>' % (spec_url, redoc_url)
'</body></html>' % (tryit_note, spec_url, redoc_url)
)
return Response(page, mimetype='text/html')
@@ -59,3 +119,92 @@ def llms_txt():
if not os.path.isfile(_LLMS):
return Response('llms.txt not found', status=404, mimetype='text/plain')
return send_file(_LLMS, mimetype='text/plain')
def swagger_tryit():
"""Swagger UI over the SAME spec the Redoc page renders.
Every URL on the page comes from url_for, so the spec and both assets stay
same-origin and correct under a mount path (prod runs under /shopdb).
"""
spec_url = url_for('docs.openapi_spec')
css_url = url_for('docs.swagger_css')
js_url = url_for('docs.swagger_js')
redoc_page_url = url_for('docs.docs_index')
page = (
'<!doctype html><html><head>'
'<title>ShopDB API - try it</title>'
'<meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<link rel="stylesheet" href="%(css)s">'
'<style>body{margin:0;padding:0}'
'.tryit-warn{font:14px/1.4 system-ui,sans-serif;background:#7f1d1d;'
'color:#fff;margin:0;padding:10px 16px}'
'.tryit-warn a{color:#fff}</style>'
'</head><body>'
'<p class="tryit-warn"><b>Execute sends a real request to this '
'server</b>, as whoever is signed in on this browser. A write '
'operation changes live data. The read-only contract is at '
'<a href="%(redoc)s">/api/docs</a>.</p>'
'<div id="swagger-ui"></div>'
'<script src="%(js)s"></script>'
'<script>\n'
'window.ui = SwaggerUIBundle({\n'
# json.dumps, NOT a bare "%s". SCRIPT_NAME is proxy-controlled and
# ProxyFix honours X-Forwarded-Prefix, so a mount path carrying a quote
# closed this string literal and executed what followed: SCRIPT_NAME of
# /a"+alert(1)+" rendered url: "/a"+alert(1)+"/api/docs/openapi.json".
# Escaping it as JSON is what makes the value data rather than code.
' url: %(spec)s,\n'
' dom_id: "#swagger-ui",\n'
' presets: [SwaggerUIBundle.presets.apis],\n'
' layout: "BaseLayout",\n'
' deepLinking: true,\n'
' filter: true,\n'
# 573 operations. Expanded by default, the page spends many seconds
# laying out before it answers a scroll.
' docExpansion: "none",\n'
' withCredentials: true,\n'
# THE air-gap setting. Left unset, Swagger UI renders a "valid" badge
# by handing this spec to the public swagger validator - an outbound
# request that cannot succeed here, and that would ship the internal
# API surface off the box if it ever could.
' validatorUrl: null\n'
'});\n'
'</script>'
'</body></html>'
% {'css': escape(css_url), 'js': escape(js_url),
'spec': json.dumps(spec_url),
'redoc': escape(redoc_page_url)}
)
return Response(page, mimetype='text/html')
def swagger_css():
"""Vendored Swagger UI stylesheet (offline)."""
return send_file(_SWAGGER_CSS, mimetype='text/css')
def swagger_js():
"""Vendored Swagger UI bundle (offline)."""
return send_file(_SWAGGER_JS, mimetype='application/javascript')
@docs_bp.record_once
def _mount_tryit(state):
"""Bind the try-it routes only when the flag is on for this app.
Binding at registration rather than checking the flag inside the view, so
that when it is off the routes DO NOT EXIST: the URL gets Flask's own 404
and confirms nothing, and the unauthenticated-GET sweep in
tests/test_core/test_public_read_surface.py never has to make an exception
for three routes that are only meant to be reachable on a dev box.
"""
if not state.app.config.get('API_DOCS_TRYIT_ENABLED'):
return
state.add_url_rule('/try', endpoint='swagger_tryit',
view_func=swagger_tryit, strict_slashes=False)
state.add_url_rule('/swagger-ui.css', endpoint='swagger_css',
view_func=swagger_css)
state.add_url_rule('/swagger-ui-bundle.min.js', endpoint='swagger_js',
view_func=swagger_js)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because one or more lines are too long

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))