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'