Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
40 changes: 32 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.

<details>
<summary>Advanced: describe the schema with raw JSON Schema instead of Pydantic</summary>

Expand Down Expand Up @@ -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: ...
```

</details>
Expand Down
30 changes: 23 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
89 changes: 61 additions & 28 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<host-key>`, 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=<key-authorized-for-both-functions>`. 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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
```

Expand All @@ -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
Expand Down Expand Up @@ -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: ...
```

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading