diff --git a/DESIGN.md b/DESIGN.md
index 2720658c..a77c9295 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -53,6 +53,19 @@ 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 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
- Runtime validation belongs to `azure-functions-validation`.
diff --git a/README.md b/README.md
index 3d029767..7d506994 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,31 @@ 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. 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
+will become `"header"` in a future minor release before 1.0.
+
Advanced: describe the schema with raw JSON Schema instead of Pydantic
@@ -335,8 +360,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/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/docs/security.md b/docs/security.md
index 0139de4f..2b2a2c29 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -55,38 +55,59 @@ 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.
+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. 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.
+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 +121,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 +204,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.
diff --git a/llms-full.txt b/llms-full.txt
index 95c7db1d..e629a25d 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,22 @@ 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. 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. 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
```python
@@ -283,6 +300,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 +314,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
diff --git a/pyproject.toml b/pyproject.toml
index 61985e45..28ce82cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -142,12 +142,14 @@ 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)",
+ "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/docs_routes.py b/src/azure_functions_openapi/docs_routes.py
index 77b35c9e..35810bc5 100644
--- a/src/azure_functions_openapi/docs_routes.py
+++ b/src/azure_functions_openapi/docs_routes.py
@@ -3,8 +3,9 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import re
-from typing import Any
+from typing import Any, Literal
from urllib.parse import urlencode
+import warnings
import azure.functions as func
@@ -41,6 +42,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:
"""Register opt-in JSON, YAML, and Swagger UI routes on ``app``.
@@ -50,10 +52,14 @@ def register_openapi_routes(
The supplied app remains the routing authority. No routes are registered
until this function is called, and ``enabled=False`` performs no mutation.
- For protected routes, a ``code`` query value on the docs request is copied
- into the specification URL. The key must authorize the JSON route; host or
- master keys span functions, while function-scoped keys do not. Keys in page
- URLs may be retained in browser history and logs.
+ For protected routes, ``docs_key_forwarding="header"`` lets the browser
+ remove ``code`` from its page URL and send it only in the same-origin spec
+ request's ``x-functions-key`` header, without server-side key echo.
+ ``"query"`` preserves legacy spec-URL forwarding; ``"none"`` never forwards.
+ The default ``None`` preserves ``"query"`` with a DeprecationWarning for
+ protected routes; it will change to ``"header"`` in a future minor before 1.0.
+ Anonymous routes never forward. The key must authorize both docs and JSON
+ functions. The initial navigation URL can still be retained in access logs.
"""
routes = (json_route, yaml_route, docs_route)
for route in routes:
@@ -63,6 +69,8 @@ def register_openapi_routes(
raise ValueError("Documentation routes must be distinct")
if not isinstance(auth_level, func.AuthLevel):
raise TypeError("auth_level must be an azure.functions.AuthLevel")
+ if docs_key_forwarding not in (None, "header", "query", "none"):
+ raise ValueError('docs_key_forwarding must be "header", "query", "none", or None')
if not name_prefix.strip():
raise ValueError("name_prefix must be non-empty")
suffixes = tuple(_function_suffix(route) for route in routes)
@@ -79,6 +87,16 @@ def register_openapi_routes(
names = ", ".join(sorted(reserved_options))
raise ValueError(f"spec_options cannot override: {names}")
+ protected = auth_level is not func.AuthLevel.ANONYMOUS
+ if protected and docs_key_forwarding is None:
+ warnings.warn(
+ "The legacy docs key-in-URL forwarding exposes reusable keys in served HTML "
+ 'and specification request URLs. Pass docs_key_forwarding="header" (or "none") '
+ 'explicitly; the default will change to "header" in a future minor release before 1.0.',
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
def openapi_json(req: func.HttpRequest) -> func.HttpResponse:
from azure_functions_openapi import get_openapi_json
@@ -115,11 +133,16 @@ def swagger_ui(req: func.HttpRequest) -> func.HttpResponse:
# actually serves it, even when json_route's own first segment
# happens to equal the configured prefix.
openapi_url = apply_host_prefix(f"/{json_route}", prefix)
- code = req.params.get("code")
- if auth_level is not func.AuthLevel.ANONYMOUS and code is not None:
- separator = "&" if "?" in openapi_url else "?"
- openapi_url = f"{openapi_url}{separator}{urlencode({'code': code})}"
- return render_swagger_ui(title=f"{title} Docs", openapi_url=openapi_url)
+ if protected and docs_key_forwarding in (None, "query"):
+ code = req.params.get("code")
+ if code is not None:
+ separator = "&" if "?" in openapi_url else "?"
+ openapi_url = f"{openapi_url}{separator}{urlencode({'code': code})}"
+ return render_swagger_ui(
+ title=f"{title} Docs",
+ openapi_url=openapi_url,
+ forward_page_key_header=protected and docs_key_forwarding == "header",
+ )
registered_json = _register_handler(app, openapi_json, json_route, auth_level, name_prefix)
registered_yaml = _register_handler(app, openapi_yaml, yaml_route, auth_level, name_prefix)
diff --git a/src/azure_functions_openapi/swagger_ui.py b/src/azure_functions_openapi/swagger_ui.py
index 1f0b8e29..e127fa37 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,38 @@ 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;
+ request.redirect = 'error';
+ }}
+ }} catch (error) {{
+ if (!(error instanceof TypeError)) throw error;
+ }}
+ }}"""
+
html_content = f"""
@@ -102,7 +139,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..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
@@ -142,6 +148,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},
)
diff --git a/tests/test_swagger_ui_key_forwarding.py b/tests/test_swagger_ui_key_forwarding.py
new file mode 100644
index 00000000..c0e6de80
--- /dev/null
+++ b/tests/test_swagger_ui_key_forwarding.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import html
+import logging
+import re
+from unittest.mock import patch
+
+import pytest
+
+from azure_functions_openapi.swagger_ui import (
+ _redact_query_for_logging,
+ _sanitize_url,
+ render_swagger_ui,
+)
+
+
+@pytest.mark.parametrize("custom_csp", [None, "default-src 'self'; script-src 'nonce-{nonce}'"])
+def test_header_script_uses_nonce_and_spec_only_guard(custom_csp: str | None) -> None:
+ # Given / When
+ response = render_swagger_ui(custom_csp=custom_csp, forward_page_key_header=True)
+ body = response.get_body().decode()
+ # Then
+ nonces = re.findall(r'