From 93a4578073f7611853e77693eb48ac3451a91b30 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:23:57 +0900 Subject: [PATCH 01/12] fix(swagger-ui): restrict page key headers to spec downloads Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/azure_functions_openapi/swagger_ui.py | 46 +++++++- tests/test_swagger_ui_key_forwarding.py | 124 ++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 tests/test_swagger_ui_key_forwarding.py diff --git a/src/azure_functions_openapi/swagger_ui.py b/src/azure_functions_openapi/swagger_ui.py index 1f0b8e29..8b16fa90 100644 --- a/src/azure_functions_openapi/swagger_ui.py +++ b/src/azure_functions_openapi/swagger_ui.py @@ -31,6 +31,8 @@ def render_swagger_ui( openapi_url: str = "/api/openapi.json", custom_csp: str | None = None, enable_client_logging: bool = False, + *, + forward_page_key_header: bool = False, ) -> HttpResponse: """ Render Swagger UI with enhanced security headers and CSP protection. @@ -40,6 +42,9 @@ def render_swagger_ui( openapi_url: URL to the OpenAPI specification custom_csp: Custom Content Security Policy (optional) enable_client_logging: Whether to enable browser-side response logging + forward_page_key_header: Read and remove the page's code query in the browser, + forwarding it only as a header on the same-origin spec download. Absolute + HTTPS spec URLs are supported but never receive the page key. Returns: HttpResponse with Swagger UI HTML and security headers @@ -83,6 +88,37 @@ def render_swagger_ui( } """ + page_key_script = "" + key_interceptor = "" + if forward_page_key_header: + allowed_js = json.dumps(sanitized_url.startswith("/") and not openapi_url.startswith("//")) + page_key_script = f""" + """ + key_interceptor = f""" + if (pageKey && specKeyForwardingAllowed && request.loadSpec === true && + !request.url.startsWith('//')) {{ + try {{ + const specUrl = new URL({safe_url_js}, window.location.href); + const requestUrl = new URL(request.url, window.location.href); + if (requestUrl.origin === window.location.origin && + requestUrl.href === specUrl.href) {{ + request.headers['x-functions-key'] = pageKey; + }} + }} catch (error) {{ + if (!(error instanceof TypeError)) throw error; + }} + }}""" + html_content = f""" @@ -102,7 +138,7 @@ def render_swagger_ui(
- ", "CANARY_KEY_9f3a" * 10000], + ids=["ordinary", "hostile", "huge"], +) +def test_header_mode_never_echoes_page_key(code: str, caplog: pytest.LogCaptureFixture) -> None: + # Given: even repeated/hostile query values are browser-owned, not rendered server-side. + caplog.set_level(logging.DEBUG, logger="azure_functions_openapi") + routes = register_openapi_routes( + func.FunctionApp(), + title="Private", + version="1", + auth_level=func.AuthLevel.FUNCTION, + docs_key_forwarding="header", + ) + assert routes.docs is not None + request = func.HttpRequest( + method="GET", + url="https://example.test/api/docs?" + + urlencode([("code", code), ("code", "CANARY_SECOND")]), + params={"code": code}, + headers={}, + body=b"", + ) + + # When + response = routes.docs(request) + + # Then: no raw or encoded key in any server-produced channel. + output = response.get_body().decode() + repr(dict(response.headers)) + repr(caplog.records) + assert "CANARY_KEY_9f3a" not in output + assert "CANARY_SECOND" not in output + assert 'url: "/api/openapi.json"' in output + assert "history.replaceState" in output + assert "no-store" in response.headers["Cache-Control"] + assert not {"Location", "Set-Cookie"}.intersection(response.headers) + assert all("CANARY" not in record.getMessage() for record in caplog.records) + + +@pytest.mark.parametrize("mode", ["query", "header", "none"]) +@pytest.mark.parametrize("auth", list(func.AuthLevel)) +def test_explicit_modes_forward_only_when_protected( + mode: Literal["query", "header", "none"], + auth: func.AuthLevel, +) -> None: + # Given + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + routes = register_openapi_routes( + func.FunctionApp(), + title="Docs", + version="1", + auth_level=auth, + docs_key_forwarding=mode, + ) + assert routes.docs is not None + + # When + body = ( + routes.docs( + func.HttpRequest( + method="GET", + url="/api/docs?code=CANARY", + params={"code": "CANARY"}, + body=b"", + ) + ) + .get_body() + .decode() + ) + + # Then + protected = auth is not func.AuthLevel.ANONYMOUS + assert ("?code=CANARY" in body) is (protected and mode == "query") + assert ("history.replaceState" in body) is (protected and mode == "header") + assert not caught + + +@pytest.mark.parametrize("auth", [func.AuthLevel.FUNCTION, func.AuthLevel.ADMIN]) +def test_default_warns_once_per_registration_at_caller(auth: func.AuthLevel) -> None: + # Given + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + line = inspect.currentframe() + assert line is not None + expected_line = line.f_lineno + 2 + # When + routes = register_openapi_routes( + func.FunctionApp(), + title="Docs", + version="1", + auth_level=auth, + ) + assert routes.docs is not None + for _ in range(2): + routes.docs(func.HttpRequest(method="GET", url="/api/docs", body=b"")) + + # Then + assert len(caught) == 1 + warning = caught[0] + assert warning.category is DeprecationWarning + assert warning.filename == __file__ + assert warning.lineno == expected_line + message = str(warning.message) + assert 'docs_key_forwarding="header"' in message + assert '"none"' in message + assert "future minor release before 1.0" in message + assert "key-in-URL" in message + assert "CANARY" not in message + + +def test_anonymous_default_does_not_warn() -> None: + # Given / When + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + register_openapi_routes(func.FunctionApp(), title="Docs", version="1") + # Then + assert not caught + + +def test_registered_handler_parameters_match_host_binding_names() -> None: + # Given + routes = register_openapi_routes(func.FunctionApp(), title="Docs", version="1") + # When / Then: the real worker rejects parameters missing from function bindings. + for handler in (routes.json, routes.yaml, routes.docs): + assert handler is not None + assert list(inspect.signature(handler).parameters) == ["req"] + + +@pytest.mark.parametrize("value", ["invalid", "CANARY_INVALID", 123, [], {}]) +def test_invalid_forwarding_is_rejected_without_echo( + value: str | int | list[str] | dict[str, str], +) -> None: + # Given: getattr deliberately exercises the untyped public boundary. + register = getattr( + importlib.import_module("azure_functions_openapi"), "register_openapi_routes" + ) + # When / Then + with pytest.raises((ValueError, TypeError), match="docs_key_forwarding") as error: + register(func.FunctionApp(), title="Docs", version="1", docs_key_forwarding=value) + assert "CANARY_INVALID" not in str(error.value) diff --git a/tests/test_docs_routes.py b/tests/test_docs_routes.py index 63d31f85..a715ebe0 100644 --- a/tests/test_docs_routes.py +++ b/tests/test_docs_routes.py @@ -142,6 +142,7 @@ def test_register_openapi_routes_supports_custom_routes_names_auth_and_options() docs_route="reference", route_prefix="gateway/", auth_level=func.AuthLevel.FUNCTION, + docs_key_forwarding="query", name_prefix="private", spec_options={"description": "Private contract", "strict": True}, ) From 6083299121df9b43a3c319a66195b3fe63a21d0d Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:23:59 +0900 Subject: [PATCH 03/12] test(docs-routes): verify protected docs no-echo on a real host Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/host_smoke/function_app.py | 13 +++++++++ tests/host_smoke/test_fixture_routes.py | 38 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tests/host_smoke/function_app.py b/tests/host_smoke/function_app.py index 010cc99a..3ccbd989 100644 --- a/tests/host_smoke/function_app.py +++ b/tests/host_smoke/function_app.py @@ -43,3 +43,16 @@ def swagger_ui(req: func.HttpRequest) -> func.HttpResponse: docs_route="registered/docs", name_prefix="registered", ) + + +protected_docs = register_openapi_routes( + app, + title="Protected host smoke", + version="1", + json_route="protected/openapi.json", + yaml_route="protected/openapi.yaml", + docs_route="protected/docs", + name_prefix="protected", + auth_level=func.AuthLevel.FUNCTION, + docs_key_forwarding="header", +) diff --git a/tests/host_smoke/test_fixture_routes.py b/tests/host_smoke/test_fixture_routes.py index 1435fd22..e109e207 100644 --- a/tests/host_smoke/test_fixture_routes.py +++ b/tests/host_smoke/test_fixture_routes.py @@ -21,11 +21,13 @@ from collections.abc import Iterator import importlib.util import json +import os from pathlib import Path from types import ModuleType import azure.functions as func import pytest +import requests from azure_functions_openapi import generate_openapi_spec from azure_functions_openapi.decorator import clear_openapi_registry @@ -88,3 +90,39 @@ def test_custom_name_route_responds_with_the_expected_body( ) -> None: response = fixture_module.python_handler_name(_get("http://127.0.0.1:7071/api/custom_name")) assert json.loads(response.get_body()) == {"function_name": "custom_name"} + + +def test_protected_docs_fixture_never_echoes_key(fixture_module: ModuleType) -> None: + # Given + request = func.HttpRequest( + method="GET", + url="/api/protected/docs?code=CANARY_KEY_9f3a", + params={"code": "CANARY_KEY_9f3a"}, + body=b"", + ) + # When + response = fixture_module.protected_docs.docs(request) + # Then + assert b"CANARY_KEY_9f3a" not in response.get_body() + assert b'url: "/api/protected/openapi.json"' in response.get_body() + + +@pytest.mark.skipif( + not os.environ.get("HOST_SMOKE_BASE_URL"), + reason="Set HOST_SMOKE_BASE_URL to a running tests/host_smoke Functions host", +) +def test_real_host_protected_docs_never_echoes_key() -> None: + # Given: Core Tools does not enforce keys locally; this proves no-echo, NOT key enforcement. + base = os.environ["HOST_SMOKE_BASE_URL"] + # When + response = requests.get( + f"{base}/api/protected/docs", + params={"code": "CANARY_KEY_9f3a"}, + timeout=10, + allow_redirects=False, + ) + # Then + assert response.status_code == 200 + assert "CANARY_KEY_9f3a" not in response.text + repr(dict(response.headers)) + assert 'url: "/api/protected/openapi.json"' in response.text + assert "?code=" not in response.text From 29f68936432b13b216e207f721d38be1a57d1ee7 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:23:59 +0900 Subject: [PATCH 04/12] test(swagger-ui): exercise key forwarding in offline Chromium Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- pyproject.toml | 3 +- tests/docs_key_browser.cjs | 69 ++++++++++++++++++++++++++++++++++ tests/test_docs_key_browser.py | 54 ++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/docs_key_browser.cjs create mode 100644 tests/test_docs_key_browser.py diff --git a/pyproject.toml b/pyproject.toml index 61985e45..67464473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,12 +142,13 @@ pretty = true module = "examples.*" follow_imports = "skip" [tool.pytest.ini_options] -addopts = "--cov=src/azure_functions_openapi --cov-report=xml --cov-report=term-missing -ra -q -m 'not e2e and not perf'" +addopts = "--cov=src/azure_functions_openapi --cov-report=xml --cov-report=term-missing -ra -q -m 'not e2e and not perf and not browser'" testpaths = ["tests"] pythonpath = ["src", "."] markers = [ "e2e: real Azure end-to-end tests (require E2E_BASE_URL)", "perf: spec-generation regression budgets (run with make perf)", + "browser: offline Chromium script checks (require cached Playwright 1.54.1 and Chromium)", ] [tool.coverage.run] diff --git a/tests/docs_key_browser.cjs b/tests/docs_key_browser.cjs new file mode 100644 index 00000000..9cc44b71 --- /dev/null +++ b/tests/docs_key_browser.cjs @@ -0,0 +1,69 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const { chromium } = require(process.argv[2]); +const payload = JSON.parse(fs.readFileSync(0, 'utf8')); + +(async () => { + let browser; + try { + browser = await chromium.launch({ headless: true }); + } catch (error) { + if (error.message.includes("Executable doesn't exist")) { + process.stderr.write('Chromium executable not installed'); + process.exitCode = 77; + return; + } + throw error; + } + const requests = []; + const server = http.createServer((request, response) => { + if (request.url.startsWith('/api/docs')) { + response.writeHead(200, { ...payload.headers, 'Content-Type': 'text/html' }); + response.end(payload.html); + } else { + requests.push({ url: request.url, key: request.headers['x-functions-key'] }); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"openapi":"3.0.0","info":{"title":"stub","version":"1"},"paths":{}}'); + } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + try { + const page = await browser.newPage(); + await page.route('https://cdn.jsdelivr.net/**', route => route.abort()); + await page.addInitScript(() => { + window.SwaggerUIBundle = config => { + window.intercept = config.requestInterceptor; + const spec = config.requestInterceptor({ url: config.url, loadSpec: true, headers: {} }); + window.specDone = fetch(spec.url, { headers: spec.headers }).then(response => response.json()); + return {}; + }; + window.SwaggerUIBundle.presets = { apis: {} }; + }); + for (const query of ['?keep=yes&code=CANARY_KEY_9f3a&code=SECOND', '?keep=yes', '?keep=yes&code=']) { + await page.goto('about:blank'); + await page.goto(`${origin}/api/docs${query}#section`); + await page.evaluate(() => window.specDone); + assert.equal(page.url(), `${origin}/api/docs?keep=yes#section`); + const specRequest = requests.filter(request => request.url === '/api/openapi.json').at(-1); + assert.equal(specRequest.key, query.includes('CANARY') ? 'CANARY_KEY_9f3a' : undefined); + assert.ok(!specRequest.url.includes('code=')); + const leaked = await page.evaluate(() => { + const urls = [ + ['/api/operation', false], ['/api/openapi.json', false], + ['https://evil.example/api/openapi.json', true], + [`//${window.location.host}/api/openapi.json`, true], + ['/api/openapi.json?other=1', true], ['https://[broken', true], + ]; + return urls.some(([url, loadSpec]) => + 'x-functions-key' in window.intercept({ url, loadSpec, headers: {} }).headers); + }); + assert.equal(leaked, false); + } + process.stdout.write('PASS: address redacted; spec header only; operation and cross-origin keys absent\n'); + } finally { + await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { process.stderr.write(error.stack); process.exitCode = 1; }); diff --git a/tests/test_docs_key_browser.py b/tests/test_docs_key_browser.py new file mode 100644 index 00000000..39cab148 --- /dev/null +++ b/tests/test_docs_key_browser.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from azure_functions_openapi.swagger_ui import render_swagger_ui + + +@pytest.mark.browser +@pytest.mark.parametrize( + "custom_csp", [None, "default-src 'self'; script-src 'nonce-{nonce}'; connect-src 'self'"] +) +def test_page_key_forwarding_in_chromium(custom_csp: str | None) -> None: + # Given: use the already-cached demo toolchain; never download a test dependency. + node = shutil.which("node") + packages = (Path.home() / ".npm/_npx").glob("*/node_modules/playwright/package.json") + package = next( + (p.parent for p in packages if json.loads(p.read_text())["version"] == "1.54.1"), None + ) + if node is None or package is None: + pytest.skip( + "Node and cached Playwright 1.54.1 are required for offline browser verification" + ) + assert node is not None and package is not None + response = render_swagger_ui(custom_csp=custom_csp, forward_page_key_header=True) + payload = json.dumps({"html": response.get_body().decode(), "headers": dict(response.headers)}) + environment = dict(os.environ) + local_browsers = Path(".cache/ms-playwright").resolve() + if local_browsers.exists(): + environment["PLAYWRIGHT_BROWSERS_PATH"] = str(local_browsers) + + # When + result = subprocess.run( + [node, str(Path(__file__).with_name("docs_key_browser.cjs")), str(package)], + input=payload, + text=True, + capture_output=True, + timeout=60, + env=environment, + ) + + # Then + if result.returncode == 77: + pytest.skip("Cached Playwright Chromium is unavailable: " + result.stderr.strip()) + assert result.returncode == 0, result.stderr + assert ( + "PASS: address redacted; spec header only; operation and cross-origin keys absent" + in result.stdout + ) From ce107bd03bac3e11053362eddf22fea94267ff37 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:24:14 +0900 Subject: [PATCH 05/12] docs(security): explain protected docs header flow and limits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 36 ++++++++++++++++----- docs/security.md | 83 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 83 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3d029767..52045b67 100644 --- a/README.md +++ b/README.md @@ -37,17 +37,17 @@ Azure Functions Python v2 has no built-in API documentation story: "/api/users": { "post": { "summary": "Create user", - "requestBody": { "...": "..." }, - "responses": { "200": { "...": "..." } } + "requestBody": {"...": "..."}, + "responses": {"200": {"...": "..."}}, } } } } + # function_app.py — no connection to the spec above @app.route(route="users", methods=["POST"]) -def create_user(req): - ... +def create_user(req): ... ``` Spec drifts. Consumers guess. No Swagger UI. @@ -73,8 +73,8 @@ class UserResponse(BaseModel): responses=UserResponse, ) @app.route(route="users", methods=["POST"]) -def create_user(req): - ... +def create_user(req): ... + # Auto-generated endpoints: # GET /api/openapi.json — generated from registered metadata and bindings @@ -308,6 +308,27 @@ def swagger_ui(req: func.HttpRequest) -> func.HttpResponse: The anonymous documentation routes above are intended for local development; review [production exposure and protection options](docs/security.md#protecting-documentation-routes) before deployment. +For key-protected docs, explicitly opt into browser-side header forwarding: + +```python +register_openapi_routes( + app, + title="Private API", + version="1.0.0", + auth_level=func.AuthLevel.FUNCTION, + docs_key_forwarding="header", +) +``` + +The page key is not echoed into HTML or the spec URL; the browser removes `code` +from its address bar and sends `x-functions-key` only on the same-origin spec +download, never Try-it-out. The initial navigation URL can still reach access +logs before replacement, and the key must authorize both functions. Prefer edge +authentication in production. The default `None` retains legacy query forwarding +with a `DeprecationWarning` for protected registrations; select `"header"`, +`"none"` (no forwarding), or explicitly `"query"` (legacy exposure). The default +will become `"header"` in a future minor release before 1.0. +
Advanced: describe the schema with raw JSON Schema instead of Pydantic @@ -335,8 +356,7 @@ review [production exposure and protection options](docs/security.md#protecting- }, ) @app.route(route="http_trigger", auth_level=func.AuthLevel.ANONYMOUS, methods=["POST"]) -def http_trigger(req: func.HttpRequest) -> func.HttpResponse: - ... +def http_trigger(req: func.HttpRequest) -> func.HttpResponse: ... ```
diff --git a/docs/security.md b/docs/security.md index 0139de4f..6ac01cf8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -55,38 +55,53 @@ if os.getenv("ENABLE_API_DOCS") == "true": Set `ENABLE_API_DOCS=true` only in environments where these routes should exist. Any other value, including an unset setting, omits them at import time. -### 2. Require an Azure Functions key for direct spec access +### 2. Protect documentation with an Azure Functions key -Functions authentication can protect direct JSON/YAML downloads, but it does -not by itself produce a working protected Swagger UI. Loading `/api/docs` with -an `x-functions-key` header or `?code=` authenticates only the page request. -`render_swagger_ui()` makes a separate browser request to `openapi_url`; it does -not forward the page's header or query parameter. A function-scoped key for the -docs function may also be unauthorized for the JSON function. - -Protect a specification route when direct, non-interactive access is needed: +When key-protected documentation is required, explicitly select header forwarding: ```python -@app.route(route="openapi.json", methods=["GET"], auth_level=func.AuthLevel.FUNCTION) -def openapi_json(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse( - get_openapi_json(title="My API", app=app), - mimetype="application/json", - ) +from azure_functions_openapi import register_openapi_routes + +register_openapi_routes( + app, + title="My API", + version="1.0.0", + auth_level=func.AuthLevel.FUNCTION, + docs_key_forwarding="header", +) ``` -Call that route with an `x-functions-key` header or a `?code=` query parameter. -Passing a key in `openapi_url`, such as -`/api/openapi.json?code=`, can make Swagger UI fetch the protected -specification, but is discouraged except in a trusted internal environment: -the key appears in the generated page and may be recorded in browser history, -proxy/access logs, or other systems that log request URLs. `render_swagger_ui()` -does not write the key to this package's own application logs, but that does -not control what other layers between the browser and the function record. -Prefer conditional registration (pattern 1) or protect the UI and -spec together with cookie/session-based edge authentication (pattern 3). An -anonymous specification route is another option only when it is conditionally -registered in trusted environments and its exposure is intentional. +Navigate to `/api/docs?code=`. The server +does **not** echo that query value into the served HTML/JavaScript or the +configured specification URL. The browser reads the key from its own page URL, +immediately removes all `code` values with `history.replaceState` (preserving +other parameters and the fragment), and sends it as `x-functions-key` **only** +on Swagger UI's same-origin specification download. The specification request +URL contains no forwarded key. Try-it-out operations, cross-origin URLs, +protocol-relative URLs, and absolute HTTPS specification URLs never receive +this page key. Without a page key, no header is added. + +**Limits:** the initial documentation navigation still contains `?code=` until +the script runs. Server/proxy/access logs and browser history before replacement +may record it; `replaceState` cannot erase earlier copies. The key also remains +in browser memory for the page lifetime. A function-scoped key authorizes only +its own function: use a host key or a key valid for both the docs and JSON +functions, not merely the docs function's key. Do not use a master key for +routine documentation access. Prefer edge authentication (pattern 3) in production. +Core Tools does not enforce function-key authentication locally, so a local +successful request proves neither key validity nor enforcement on Azure. + +For direct JSON/YAML downloads, send an `x-functions-key` header. The low-level +renderer can opt in with `render_swagger_ui(forward_page_key_header=True)`; +it cannot read or forward a header from the initial page request. + +**Migration:** `docs_key_forwarding=None` currently retains legacy query forwarding +and emits a `DeprecationWarning` once per protected registration. Silence it by +choosing `"header"` explicitly (recommended), or `"none"` for edge-authenticated +or intentionally anonymous specifications. Explicit `"query"` retains the old +key-in-HTML/spec-URL exposure and is only a compatibility escape hatch. The +default will change to `"header"` in a future minor release before 1.0. +Anonymous registrations never forward a page key, in any mode. ### 3. Enforce access at the platform edge @@ -100,6 +115,11 @@ private networking). ### Swagger UI Try-it-out behavior +**Local demonstration versus production:** the default anonymous documentation +registration and enabled Try-it-out are development conveniences, not production +access controls. Make production exposure deliberate using patterns 1–3 above, +and authenticate API operations independently of documentation access. + `render_swagger_ui()` enables Try-it-out and allows all supported submit methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, and `OPTIONS`. Anyone who can open the UI can therefore initiate browser-originated calls to documented API @@ -178,10 +198,17 @@ You can provide a custom CSP policy: ```python from azure_functions_openapi.swagger_ui import render_swagger_ui -custom_csp = "default-src 'self'; script-src 'self'" +custom_csp = ( + "default-src 'self'; script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self'" +) response = render_swagger_ui(custom_csp=custom_csp) ``` +Keep `{nonce}` in `script-src` so the renderer's inline scripts can run. The +placeholder is replaced consistently in the response header and HTML-escaped +meta tag. The default policy permits only same-origin spec/API connections. + ### Security Headers Security headers are automatically added to all responses. You can customize them by modifying the `render_swagger_ui` function. From bcb326653d82c3b9f28b30873ec3e968726ccced Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:24:14 +0900 Subject: [PATCH 06/12] docs(docs-routes): document key forwarding API and migration Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- DESIGN.md | 7 +++++++ docs/api.md | 30 +++++++++++++++++++++++------- llms-full.txt | 15 +++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2720658c..053d3bab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -53,6 +53,13 @@ flowchart TD Note: `render_swagger_ui()` does not embed or generate the OpenAPI spec. It returns HTML that instructs the browser to fetch the spec from a configured URL. +Protected docs can opt into browser-owned key forwarding: the server never reads +the page key in header mode. A nonce-protected script removes `code` from the +page URL before loading Swagger UI and adds `x-functions-key` only to the exact +same-origin spec download (`loadSpec`), never API operations. Absolute HTTPS +spec URLs are non-forwardable; protocol-relative URLs are rejected. Legacy query +forwarding remains the default with a protected-registration deprecation warning. + ## Integration Boundaries - Runtime validation belongs to `azure-functions-validation`. diff --git a/docs/api.md b/docs/api.md index f05bc419..a0ea4e6d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -82,13 +82,29 @@ generated spec paths and the URL fetched by Swagger UI. Additional generation arguments such as `description`, `strict`, or `servers` can be supplied through `spec_options`. -When `auth_level` is not `ANONYMOUS`, the docs handler forwards a `code` query -parameter from the docs request to the JSON specification URL so Swagger UI can -load it. The key must authorize the JSON route: a host/master key spans -functions, while a function-scoped key authorizes only its own function. The -key remains in the page URL and can appear in browser history and logs, so use -this only in trusted/internal environments; prefer edge-managed session or -cookie authentication for broader access. +The keyword-only `docs_key_forwarding: Literal["header", "query", "none"] | None = None` +controls protected documentation keys: + +| Value | Behavior | +| --- | --- | +| `"header"` | Recommended: no server-side key echo; the browser removes `code` from its page URL and sends `x-functions-key` only on the exact same-origin spec download, never Try-it-out | +| `"query"` | Legacy: copies the page request's `code` into the served spec URL | +| `"none"` | No forwarding; suitable for edge authentication or an anonymous spec | +| `None` (default) | Legacy query behavior with a caller-facing `DeprecationWarning` for protected registrations | + +Anonymous routes never forward keys or warn. Explicit values suppress the warning; +the default will become `"header"` in a future minor release before 1.0. The +initial page navigation can still expose the key in access logs before browser +URL replacement. A function-scoped key authorizes only its own function; the +key must authorize both the docs and JSON functions. Prefer production edge auth. +See [the protected-docs threat model](security.md#protecting-documentation-routes). + +The low-level `render_swagger_ui(..., *, forward_page_key_header=False)` preserves +existing output by default. Opting in reads the page query in the browser, not +on the server. Only root-relative spec URLs are eligible for header forwarding; +absolute HTTPS URLs remain supported but never receive the key, and +protocol-relative URLs are rejected. Custom CSP policies need `{nonce}` in +`script-src` for the inline scripts. ## Decorator behavior model diff --git a/llms-full.txt b/llms-full.txt index 95c7db1d..290a7ba0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -142,6 +142,7 @@ def register_openapi_routes( name_prefix: str = "openapi", spec_options: Mapping[str, Any] | None = None, enabled: bool = True, + docs_key_forwarding: Literal["header", "query", "none"] | None = None, ) -> DocsRoutes: ... ``` @@ -150,6 +151,15 @@ the supplied Azure Functions app or blueprint. It performs no automatic registration and returns the registered handlers for testing. `enabled=False` registers nothing and returns a `DocsRoutes` whose fields are all `None`. +For protected routes, explicitly choose `docs_key_forwarding="header"` to avoid +server-side key echo: the browser removes `code` from its page URL and sends an +`x-functions-key` header only on the exact same-origin spec download, never +Try-it-out. `"none"` disables forwarding; `"query"` preserves legacy exposure. +The default `None` retains query forwarding with a DeprecationWarning for +protected registrations and will become `"header"` in a future minor before 1.0. +Anonymous registrations never forward. Initial navigation URLs can still reach +access logs; the key must authorize both functions. Prefer production edge auth. + ### clear_openapi_registry ```python @@ -283,6 +293,8 @@ def render_swagger_ui( openapi_url: str = "/api/openapi.json", custom_csp: str | None = None, enable_client_logging: bool = False, + *, + forward_page_key_header: bool = False, ) -> HttpResponse: ... ``` @@ -295,6 +307,9 @@ def render_swagger_ui( - `openapi_url`: URL to the OpenAPI spec JSON - `custom_csp`: Custom Content Security Policy (optional) - `enable_client_logging`: Enable browser-side response logging +- `forward_page_key_header`: Browser-only page key forwarding to the exact same-origin + root-relative spec download; disabled by default. Never forwards to API operations + or absolute HTTPS/protocol-relative spec URLs. Custom CSP requires `{nonce}`. ### Types From 8f69463b30c6964f7abe72ff4935d20f0d2ee81f Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Thu, 22 Oct 2026 23:32:57 +0900 Subject: [PATCH 07/12] test(docs-routes): register the binding-name guard with explicit key forwarding --- tests/test_docs_routes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_docs_routes.py b/tests/test_docs_routes.py index a715ebe0..db4f24d8 100644 --- a/tests/test_docs_routes.py +++ b/tests/test_docs_routes.py @@ -117,7 +117,13 @@ def test_registered_handlers_declare_a_parameter_for_every_binding( do) cannot see that, so mirror the worker's check here. """ app = _documented_app() - register_openapi_routes(app, title="Widget API", version="2.0.0", auth_level=auth_level) + register_openapi_routes( + app, + title="Widget API", + version="2.0.0", + auth_level=auth_level, + docs_key_forwarding="header", + ) functions = iter_functions(app)[-3:] assert len(functions) == 3 From 9da921467488e3aab9a817a770bb0a8ff9d0f717 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 24 Oct 2026 08:15:49 +0900 Subject: [PATCH 08/12] fix(swagger-ui): reject redirects on key-bearing spec downloads Refs #812. Real CDN Chromium regressions prove fail-closed redirects without changing default or legacy-query rendering. --- pyproject.toml | 1 + src/azure_functions_openapi/swagger_ui.py | 1 + tests/docs_key_browser.cjs | 9 +- tests/docs_key_cdn_browser.cjs | 127 ++++++++++++++++++++++ tests/test_docs_key_browser.py | 60 ++++++++++ tests/test_swagger_ui_key_forwarding.py | 1 + 6 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/docs_key_cdn_browser.cjs diff --git a/pyproject.toml b/pyproject.toml index 67464473..28ce82cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,6 +149,7 @@ markers = [ "e2e: real Azure end-to-end tests (require E2E_BASE_URL)", "perf: spec-generation regression budgets (run with make perf)", "browser: offline Chromium script checks (require cached Playwright 1.54.1 and Chromium)", + "browser_cdn: real SRI-enforced Swagger UI CDN checks (skip when CDN network is unavailable)", ] [tool.coverage.run] diff --git a/src/azure_functions_openapi/swagger_ui.py b/src/azure_functions_openapi/swagger_ui.py index 8b16fa90..e127fa37 100644 --- a/src/azure_functions_openapi/swagger_ui.py +++ b/src/azure_functions_openapi/swagger_ui.py @@ -113,6 +113,7 @@ def render_swagger_ui( if (requestUrl.origin === window.location.origin && requestUrl.href === specUrl.href) {{ request.headers['x-functions-key'] = pageKey; + request.redirect = 'error'; }} }} catch (error) {{ if (!(error instanceof TypeError)) throw error; diff --git a/tests/docs_key_browser.cjs b/tests/docs_key_browser.cjs index 9cc44b71..f7a000e2 100644 --- a/tests/docs_key_browser.cjs +++ b/tests/docs_key_browser.cjs @@ -7,7 +7,9 @@ const payload = JSON.parse(fs.readFileSync(0, 'utf8')); (async () => { let browser; try { - browser = await chromium.launch({ headless: true }); + browser = await chromium.launch({ headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: ['--no-sandbox'] }); } catch (error) { if (error.message.includes("Executable doesn't exist")) { process.stderr.write('Chromium executable not installed'); @@ -36,7 +38,8 @@ const payload = JSON.parse(fs.readFileSync(0, 'utf8')); window.SwaggerUIBundle = config => { window.intercept = config.requestInterceptor; const spec = config.requestInterceptor({ url: config.url, loadSpec: true, headers: {} }); - window.specDone = fetch(spec.url, { headers: spec.headers }).then(response => response.json()); + window.specDone = fetch(spec.url, spec).then(response => response.json()); + window.specRedirect = spec.redirect; return {}; }; window.SwaggerUIBundle.presets = { apis: {} }; @@ -48,6 +51,8 @@ const payload = JSON.parse(fs.readFileSync(0, 'utf8')); assert.equal(page.url(), `${origin}/api/docs?keep=yes#section`); const specRequest = requests.filter(request => request.url === '/api/openapi.json').at(-1); assert.equal(specRequest.key, query.includes('CANARY') ? 'CANARY_KEY_9f3a' : undefined); + assert.equal(await page.evaluate(() => window.specRedirect), + query.includes('CANARY') ? 'error' : undefined); assert.ok(!specRequest.url.includes('code=')); const leaked = await page.evaluate(() => { const urls = [ diff --git a/tests/docs_key_cdn_browser.cjs b/tests/docs_key_cdn_browser.cjs new file mode 100644 index 00000000..a83f1f80 --- /dev/null +++ b/tests/docs_key_cdn_browser.cjs @@ -0,0 +1,127 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const { chromium } = require(process.argv[2]); +const payload = JSON.parse(fs.readFileSync(0, 'utf8')); +const canary = 'REDIRECT_CANARY_823'; +const spec = JSON.stringify({ + openapi: '3.0.0', info: { title: 'Redirect safety fixture', version: '1' }, + paths: { '/api/ping': { get: { responses: { 200: { description: 'OK' } } } } }, +}); +const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const close = server => new Promise(resolve => server.close(resolve)); +const originOf = server => `http://127.0.0.1:${server.address().port}`; + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: ['--no-sandbox'], + }); + const requests = []; + const reply = (request, response) => { + requests.push({ origin: request.headers.host, method: request.method, + url: request.url, key: request.headers['x-functions-key'] ?? null }); + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Headers', 'x-functions-key,x-requested-with'); + if (request.method === 'OPTIONS') { + response.writeHead(204).end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(spec); + }; + const destination = http.createServer(reply); + await listen(destination); + const server = http.createServer((request, response) => { + if (request.url.startsWith('/api/docs')) { + response.writeHead(200, { ...payload.headers, 'Content-Type': 'text/html' }); + response.end(payload.html); + } else if (request.url === '/api/openapi.json' && payload.scenario !== 'normal') { + requests.push({ origin: request.headers.host, method: request.method, + url: request.url, key: request.headers['x-functions-key'] ?? null }); + const target = payload.scenario === 'cross-origin' + ? `${originOf(destination)}/redirected-operation` + : payload.scenario === 'chain' ? '/redirect-hop' : '/redirected-operation'; + response.writeHead(payload.status, { Location: target }).end(); + } else if (request.url === '/redirect-hop') { + requests.push({ origin: request.headers.host, method: request.method, + url: request.url, key: request.headers['x-functions-key'] ?? null }); + response.writeHead(308, { Location: `${originOf(destination)}/redirected-operation` }).end(); + } else { + reply(request, response); + } + }); + await listen(server); + const origin = originOf(server); + try { + const page = await browser.newPage(); + const failedAssets = []; + const assetResponses = []; + page.on('requestfailed', request => { + if (request.url().startsWith('https://cdn.jsdelivr.net/')) { + failedAssets.push(request.failure()?.errorText); + } + }); + page.on('response', response => { + if (response.url().startsWith('https://cdn.jsdelivr.net/')) assetResponses.push(response); + }); + // Observe the actual native-fetch options without changing their behavior. + await page.addInitScript(() => { + const nativeFetch = window.fetch; + window.fetchCalls = []; + window.fetch = (url, options) => { + window.fetchCalls.push({ url: String(url), redirect: options?.redirect, + key: options?.headers?.['x-functions-key'] }); + return nativeFetch(url, options); + }; + }); + await page.goto(`${origin}/api/docs?keep=yes&code=${canary}#section`); + if (await page.evaluate(() => typeof SwaggerUIBundle === 'undefined')) { + const networkErrors = failedAssets.filter(error => + /ERR_(NAME_NOT_RESOLVED|CONNECTION|TIMED_OUT|INTERNET_DISCONNECTED)/.test(error)); + if (networkErrors.length) { + process.stderr.write(`CDN unavailable: ${networkErrors.join(', ')}`); + process.exitCode = 77; + return; + } + throw new Error(`CDN bundle did not execute (including possible SRI failure): ${failedAssets}`); + } + assert.equal(await page.locator('script[src][integrity][crossorigin="anonymous"]').count(), 1); + assert.ok(assetResponses.some(response => + response.url().includes('swagger-ui-dist@5.32.4/swagger-ui-bundle.js') && response.ok())); + assert.equal(page.url(), `${origin}/api/docs?keep=yes#section`); + assert.ok((await page.evaluate(() => performance.getEntriesByType('navigation')[0].name)) + .includes(`code=${canary}`)); + + if (payload.scenario === 'normal') { + await page.locator('.opblock-summary').click(); + await page.getByRole('button', { name: 'Execute', exact: true }).click(); + await page.locator('.responses-inner .response-col_status').filter({ hasText: '200' }).first() + .waitFor(); + await page.evaluate(() => fetch('/favicon.ico')); + assert.ok(requests.some(request => request.url === '/api/ping')); + assert.ok(requests.some(request => request.url === '/favicon.ico')); + } else { + // Wait for Swagger's own completed download error, not merely a failed fetch event. + await page.getByText('Failed to load API definition.').waitFor({ timeout: 15000 }); + assert.equal(await page.locator('.opblock').count(), 0); + } + const specRequests = requests.filter(request => request.url === '/api/openapi.json'); + assert.equal(specRequests.length, 1); + assert.equal(specRequests[0].key, canary); + const otherRequests = requests.filter(request => request.url !== '/api/openapi.json'); + assert.ok(otherRequests.every(request => request.key === null && + !new URL(request.url, origin).searchParams.has('code'))); + if (payload.scenario !== 'normal') { + assert.ok(otherRequests.every(request => request.url === '/favicon.ico')); + } + const fetchCalls = await page.evaluate(() => window.fetchCalls); + assert.equal(fetchCalls.find(request => request.key === canary).redirect, 'error'); + process.stdout.write(`PASS ${payload.scenario}/${payload.status}: ${JSON.stringify(requests)}\n`); + } finally { + process.stdout.write(`REQUEST_LOG ${JSON.stringify(requests)}\n`); + await browser.close(); + await Promise.all([close(server), close(destination)]); + } +})().catch(error => { process.stderr.write(error.stack); process.exitCode = 1; }); diff --git a/tests/test_docs_key_browser.py b/tests/test_docs_key_browser.py index 39cab148..0847e940 100644 --- a/tests/test_docs_key_browser.py +++ b/tests/test_docs_key_browser.py @@ -30,6 +30,9 @@ def test_page_key_forwarding_in_chromium(custom_csp: str | None) -> None: response = render_swagger_ui(custom_csp=custom_csp, forward_page_key_header=True) payload = json.dumps({"html": response.get_body().decode(), "headers": dict(response.headers)}) environment = dict(os.environ) + executable = Path.home() / ".cache/ms-playwright/chromium-1248/chrome-linux64/chrome" + if executable.exists(): + environment.setdefault("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", str(executable)) local_browsers = Path(".cache/ms-playwright").resolve() if local_browsers.exists(): environment["PLAYWRIGHT_BROWSERS_PATH"] = str(local_browsers) @@ -52,3 +55,60 @@ def test_page_key_forwarding_in_chromium(custom_csp: str | None) -> None: "PASS: address redacted; spec header only; operation and cross-origin keys absent" in result.stdout ) + + +@pytest.mark.browser +@pytest.mark.browser_cdn +@pytest.mark.parametrize( + ("scenario", "status"), + [ + ("normal", 200), + ("same-origin", 302), + ("cross-origin", 302), + ("same-origin", 307), + ("same-origin", 308), + ("chain", 302), + ], +) +def test_real_swagger_bundle_rejects_key_bearing_redirects(scenario: str, status: int) -> None: + # Given: real, SRI-enforced CDN assets and permissive cross-origin policy where needed. + node = shutil.which("node") + packages = (Path.home() / ".npm/_npx").glob("*/node_modules/playwright/package.json") + package = next( + (p.parent for p in packages if json.loads(p.read_text())["version"] == "1.54.1"), None + ) + if node is None or package is None: + pytest.skip("Node and cached Playwright 1.54.1 are required") + executable = Path.home() / ".cache/ms-playwright/chromium-1248/chrome-linux64/chrome" + environment = dict(os.environ) + if executable.exists(): + environment.setdefault("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", str(executable)) + csp = None + if scenario in {"cross-origin", "chain"}: + csp = ( + "default-src 'self'; script-src 'nonce-{nonce}' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src *" + ) + response = render_swagger_ui(custom_csp=csp, forward_page_key_header=True) + payload = json.dumps( + { + "html": response.get_body().decode(), + "headers": dict(response.headers), + "scenario": scenario, + "status": status, + } + ) + # When: Chromium executes the real bundle against actual local HTTP redirect responses. + result = subprocess.run( + [node, str(Path(__file__).with_name("docs_key_cdn_browser.cjs")), str(package)], + input=payload, + text=True, + capture_output=True, + timeout=90, + env=environment, + ) + # Then: transport logs and Swagger's UI must show fail-closed behavior (or normal success). + if result.returncode == 77: + pytest.skip(result.stderr.strip()) + assert result.returncode == 0, result.stdout + result.stderr + print(result.stdout) diff --git a/tests/test_swagger_ui_key_forwarding.py b/tests/test_swagger_ui_key_forwarding.py index 8a846991..c0e6de80 100644 --- a/tests/test_swagger_ui_key_forwarding.py +++ b/tests/test_swagger_ui_key_forwarding.py @@ -32,6 +32,7 @@ def test_header_script_uses_nonce_and_spec_only_guard(custom_csp: str | None) -> assert "requestUrl.origin === window.location.origin" in body assert "requestUrl.href === specUrl.href" in body assert "request.headers['x-functions-key'] = pageKey" in body + assert "request.redirect = 'error'" in body for forbidden in ("localStorage", "sessionStorage", "document.cookie", "console.log"): assert forbidden not in body From 3447a9be3399497e0f89bd6f63aab1ec804ce21f Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 24 Oct 2026 08:15:54 +0900 Subject: [PATCH 09/12] docs(security): name redirect rejection and navigation timing exposure --- docs/security.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/security.md b/docs/security.md index 6ac01cf8..2b2a2c29 100644 --- a/docs/security.md +++ b/docs/security.md @@ -80,11 +80,17 @@ on Swagger UI's same-origin specification download. The specification request URL contains no forwarded key. Try-it-out operations, cross-origin URLs, protocol-relative URLs, and absolute HTTPS specification URLs never receive this page key. Without a page key, no header is added. +When the key header is attached, the specification fetch uses `redirect: 'error'`: +any redirect fails closed with Swagger UI's normal specification-load error, +without requesting the redirect destination, even on the same origin. **Limits:** the initial documentation navigation still contains `?code=` until the script runs. Server/proxy/access logs and browser history before replacement may record it; `replaceState` cannot erase earlier copies. The key also remains -in browser memory for the page lifetime. A function-scoped key authorizes only +in browser memory for the page lifetime. In particular, +`performance.getEntriesByType("navigation")[0].name` retains the original +`?code=` navigation URL after `history.replaceState`; cleaning the address bar +does not clear this page-lifetime navigation-timing entry. A function-scoped key authorizes only its own function: use a host key or a key valid for both the docs and JSON functions, not merely the docs function's key. Do not use a master key for routine documentation access. Prefer edge authentication (pattern 3) in production. From 144accd3a2551f905e4a4932954222cfd4bc955a Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 24 Oct 2026 08:15:55 +0900 Subject: [PATCH 10/12] docs(design): clarify host receipt and header-mode credential scope --- DESIGN.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 053d3bab..a77c9295 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -53,12 +53,18 @@ flowchart TD Note: `render_swagger_ui()` does not embed or generate the OpenAPI spec. It returns HTML that instructs the browser to fetch the spec from a configured URL. -Protected docs can opt into browser-owned key forwarding: the server never reads -the page key in header mode. A nonce-protected script removes `code` from the +Protected docs can opt into browser-owned key forwarding: the library's header-mode +handler does not read or echo the query value, while the Functions host necessarily +receives the initial URL. A nonce-protected script removes `code` from the page URL before loading Swagger UI and adds `x-functions-key` only to the exact same-origin spec download (`loadSpec`), never API operations. Absolute HTTPS spec URLs are non-forwardable; protocol-relative URLs are rejected. Legacy query forwarding remains the default with a protected-registration deprecation warning. +Key-bearing specification fetches use `redirect: 'error'`: a redirect fails closed +with Swagger UI's normal load error and no request to its destination. Address-bar +cleanup cannot erase earlier logs/history or page-lifetime browser memory: +`performance.getEntriesByType("navigation")[0].name` retains the original `?code=` +URL after `history.replaceState`. ## Integration Boundaries From 2a92fe673c7d265ac5febfb8759dfe34018aad76 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 24 Oct 2026 08:15:55 +0900 Subject: [PATCH 11/12] docs(readme): explain protected spec redirects and residual URLs --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52045b67..7d506994 100644 --- a/README.md +++ b/README.md @@ -322,8 +322,12 @@ register_openapi_routes( The page key is not echoed into HTML or the spec URL; the browser removes `code` from its address bar and sends `x-functions-key` only on the same-origin spec -download, never Try-it-out. The initial navigation URL can still reach access -logs before replacement, and the key must authorize both functions. Prefer edge +download, never Try-it-out. A redirect on this key-bearing spec request fails +closed with Swagger UI's normal load error; the destination is not requested. +The initial navigation URL can still reach access logs before replacement. +Browser navigation timing (`performance.getEntriesByType("navigation")[0].name`) +retains the original `?code=` URL after `history.replaceState` in page-lifetime +memory. The key must authorize both functions. Prefer edge authentication in production. The default `None` retains legacy query forwarding with a `DeprecationWarning` for protected registrations; select `"header"`, `"none"` (no forwarding), or explicitly `"query"` (legacy exposure). The default From 2c727e77e099751afa140364e48660d2e35f859c Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 24 Oct 2026 08:15:55 +0900 Subject: [PATCH 12/12] docs(reference): document redirect and navigation timing limits --- llms-full.txt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 290a7ba0..e629a25d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -154,11 +154,18 @@ registers nothing and returns a `DocsRoutes` whose fields are all `None`. For protected routes, explicitly choose `docs_key_forwarding="header"` to avoid server-side key echo: the browser removes `code` from its page URL and sends an `x-functions-key` header only on the exact same-origin spec download, never -Try-it-out. `"none"` disables forwarding; `"query"` preserves legacy exposure. +Try-it-out. A redirect on the key-bearing spec request fails closed with Swagger +UI's normal load error (`redirect: 'error'`); the destination is not requested. +`"none"` disables forwarding; `"query"` preserves legacy exposure. The default `None` retains query forwarding with a DeprecationWarning for protected registrations and will become `"header"` in a future minor before 1.0. Anonymous registrations never forward. Initial navigation URLs can still reach -access logs; the key must authorize both functions. Prefer production edge auth. +access logs. Browser navigation timing +(`performance.getEntriesByType("navigation")[0].name`) retains the original +`?code=` URL after `history.replaceState` in page-lifetime memory. The library's +header-mode handler does not read or echo the query value, but the Functions +host necessarily receives the initial URL. The key must authorize both +functions. Prefer production edge auth. ### clear_openapi_registry