Skip to content

Commit 19effd1

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
docs: actualize guides after 0.1.6/0.1.7 — security warnings on flags, GraphQL defaults, gRPC RPC cap, static-response cache, credential-compare note
1 parent 0ba266d commit 19effd1

5 files changed

Lines changed: 168 additions & 11 deletions

File tree

docs/guide/feature-flags.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ async def checkout(flags: Flags = Depends(get_flags)):
2020
return {"flow": "v1"}
2121
```
2222

23-
`get_flags` automatically builds an `EvalContext` from the incoming request headers (`x-user-id`, `x-tenant-id`) and returns a `Flags` instance backed by `app.flags`.
23+
`get_flags` returns a `Flags` instance backed by `app.flags` with an `EvalContext` that exposes the raw request headers via `ctx.headers`. **It does not infer identity from headers**`ctx.user_id` and `ctx.tenant_id` are always `None`. Identity must come from an authenticated dependency that *you* wire in (see [EvalContext](#evalcontext)).
24+
25+
!!! warning "Why identity isn't lifted from headers (CWE-290)"
26+
Previous releases (< 0.1.6) read `X-User-Id` / `X-Tenant-Id` request headers straight into `ctx.user_id` / `ctx.tenant_id`. That trusted attacker-controlled input as the targeting identity — any client could claim any user / tenant by setting the header and bypass flag gates on admin previews, beta features, or pricing experiments. 0.1.6 removed that read; the headers are still on `ctx.headers` for non-identity targeting (region, A/B variant, locale).
2427

2528
---
2629

@@ -126,7 +129,31 @@ ctx = EvalContext(
126129
)
127130
```
128131

129-
`get_flags` auto-populates `user_id` and `tenant_id` from `x-user-id` / `x-tenant-id` request headers. Custom providers can use these fields to implement percentage rollouts, user allowlists, and tenant overrides.
132+
`get_flags` always returns an `EvalContext` with `user_id=None` / `tenant_id=None` — see the warning in [Quick start](#quick-start). Build a richer context from an authenticated dependency yourself:
133+
134+
```python
135+
from hawkapi import Depends
136+
from hawkapi.flags import EvalContext, Flags, get_flags
137+
138+
async def authed_flags(
139+
flags: Flags = Depends(get_flags),
140+
user = Depends(current_user), # your own auth dependency
141+
) -> Flags:
142+
return Flags(
143+
flags._provider, # type: ignore[attr-defined]
144+
EvalContext(
145+
user_id=user.id,
146+
tenant_id=user.tenant_id,
147+
attrs={"plan": user.plan},
148+
),
149+
)
150+
151+
@app.get("/checkout")
152+
async def checkout(flags: Flags = Depends(authed_flags)) -> dict:
153+
...
154+
```
155+
156+
Custom providers can use these fields to implement percentage rollouts, user allowlists, and tenant overrides — once identity is derived from a trusted source.
130157

131158
---
132159

docs/guide/graphql.md

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,45 @@ The base context always contains:
127127
## GraphiQL toggle
128128

129129
The interactive GraphiQL UI is served on `GET /graphql` when a browser `Accept`
130-
header prefers `text/html`:
130+
header prefers `text/html`. **Disabled by default** since 0.1.6 — opt in for
131+
development environments only:
131132

132133
```python
133-
# Enabled by default
134+
# Default — UI is OFF, GET on /graphql returns 404 for browsers
135+
app.mount_graphql("/graphql", executor=executor)
136+
137+
# Enable explicitly for local development
134138
app.mount_graphql("/graphql", executor=executor, graphiql=True)
139+
```
140+
141+
The bundled HTML pins exact CDN versions of React + GraphiQL and includes
142+
`integrity=` SRI hashes on every `<script>` / `<link>` so a compromised CDN
143+
cannot inject arbitrary JS.
144+
145+
## Depth and timeout limits
146+
147+
Every mounted endpoint enforces two DoS-protection budgets out of the box
148+
(since 0.1.6):
149+
150+
| Argument | Default | Disable | What it caps |
151+
|-------------|--------:|----------------|-------------------------------------------|
152+
| `max_depth` | `15` | `None` | Maximum brace-nesting depth of the query |
153+
| `timeout_s` | `30.0` | `None` | Wall-clock budget for `await executor(...)` |
135154

136-
# Disable for production APIs that don't need the explorer
137-
app.mount_graphql("/graphql", executor=executor, graphiql=False)
155+
Override per mount:
156+
157+
```python
158+
app.mount_graphql(
159+
"/graphql",
160+
executor=executor,
161+
max_depth=8, # tighter cap for an unauthenticated endpoint
162+
timeout_s=5.0, # tight tail-latency budget
163+
)
138164
```
139165

166+
Queries that exceed `max_depth` are rejected with HTTP **400** before the
167+
executor is invoked. Queries that exceed `timeout_s` return HTTP **504**.
168+
140169
## Disabling GET queries
141170

142171
Restrict the endpoint to POST-only:
@@ -147,6 +176,13 @@ app.mount_graphql("/graphql", executor=executor, allow_get=False)
147176

148177
GET requests will receive HTTP **405** when `allow_get=False`.
149178

179+
## GET-vs-mutation guard (CWE-352)
180+
181+
Multi-operation documents are now parsed before the GET-method check. A
182+
document containing both a query and a mutation with `?operationName=B`
183+
selecting the mutation is rejected over GET — closes the CSRF vector that
184+
allowed image tags / cache-poisoning to trigger writes.
185+
150186
## Roadmap
151187

152188
- Multipart request support (file uploads per the GraphQL multipart spec)

docs/guide/grpc.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ app.mount_grpc(
170170
)
171171
```
172172

173+
## Concurrent-RPC cap
174+
175+
Since 0.1.6 every server starts with `maximum_concurrent_rpcs=1000` — a single
176+
peer cannot open enough RPCs to exhaust the event loop or FD table. HTTP-side
177+
rate-limit / bulkhead middleware does **not** apply to the gRPC port, so this
178+
in-band cap is the only protection layer for the gRPC transport.
179+
180+
```python
181+
app.mount_grpc(
182+
MyGreeter(),
183+
add_to_server=add_GreeterServicer_to_server,
184+
maximum_concurrent_rpcs=500, # tighter than default for a public endpoint
185+
# maximum_concurrent_rpcs=None # opt out (previous behaviour)
186+
)
187+
```
188+
173189
## Multiple services on one port
174190

175191
Call `mount_grpc` twice with the **same port** — servicers are merged onto one

docs/guide/performance.md

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,61 @@ languages. For latency-sensitive deployments you can squeeze additional
66
throughput by compiling HawkAPI's hot Python modules with
77
[mypyc](https://mypyc.readthedocs.io/).
88

9-
## When to enable mypyc
9+
## Automatic fast paths
10+
11+
Two dispatcher fast paths trigger at route-registration time — no opt-in flag,
12+
no runtime overhead, no behavioural difference.
13+
14+
### Static-response cache (Wave 4, since 0.1.7)
15+
16+
Handlers whose body is exactly `return SomeResponse(literal_args)` with no
17+
parameters have their two ASGI messages (`http.response.start` +
18+
`http.response.body`) built **once** at registration time and re-emitted on
19+
every request. No handler call, no Response allocation, no header construction
20+
per request.
21+
22+
```python
23+
@app.get("/plaintext")
24+
async def plaintext() -> PlainTextResponse:
25+
return PlainTextResponse("Hello, World!")
26+
27+
@app.get("/health")
28+
async def health() -> JSONResponse:
29+
return JSONResponse({"status": "ok"})
30+
```
31+
32+
Local micro-benchmark on Darwin / Python 3.13: plaintext handler at
33+
**0.89 µs / request (1.1 M req/s on ASGI directly)** vs the previous
34+
trivial-path 6.76 µs / request — a 7.5× speedup for static endpoints.
35+
36+
Eligible handlers:
37+
38+
- No parameters of any kind (no `Request`, no `Depends`, no path/query)
39+
- Async function (sync handlers fall through to the regular path)
40+
- Single `return` statement (an optional docstring is allowed)
41+
- Return value is a `Call` to `Response`, `PlainTextResponse`, `JSONResponse`,
42+
or `HTMLResponse` by bare name
43+
- Every positional and keyword argument is a literal (`Constant`, list,
44+
tuple, set, dict of literals, or unary +/− of a numeric constant)
45+
46+
Anything else falls through to the trivial / general path. No regression on
47+
dynamic handlers.
48+
49+
### Trivial-route fast path (Wave 3, since 0.1.4)
50+
51+
When the static-response cache does not apply but the route has no DI, no
52+
`Depends(...)`, no permissions, no background tasks, no `response_model`, no
53+
deprecation headers, and no per-route middleware, the dispatcher takes a
54+
slimmer execution path that skips the DI scope, cleanup stack, and exception
55+
classifier branches. Path / query params are still coerced via `_coerce_fast`
56+
(since 0.1.5).
57+
58+
The eligibility flag is computed once at registration; the per-request branch
59+
is a single boolean check.
60+
61+
## Manual fast paths
62+
63+
### When to enable mypyc
1064

1165
Enable mypyc when:
1266

@@ -26,7 +80,7 @@ Skip mypyc when:
2680
The default `pip install hawkapi` always installs the pure-Python wheel; mypyc
2781
compilation is strictly opt-in.
2882

29-
## Installing the mypyc-compiled build
83+
### Installing the mypyc-compiled build
3084

3185
The build is gated by the `HAWKAPI_BUILD_MYPYC` environment variable.
3286

@@ -59,7 +113,7 @@ python -c "import hawkapi.routing._radix_tree; print(hawkapi.routing._radix_tree
59113
Pre-built mypyc wheels for common platforms may be published in a future
60114
release; until then, building from source is required.
61115

62-
## What gets compiled
116+
### What gets compiled
63117

64118
The build hook compiles only the request-routing hot path. Response classes are
65119
intentionally left interpreted because user code (and the bundled
@@ -74,7 +128,7 @@ ones.
74128
| `hawkapi.routing.param_converters` | Path parameter coercion. |
75129
| `hawkapi.middleware._pipeline` | Middleware chain assembly at startup. |
76130

77-
## Expected gains
131+
### Expected gains
78132

79133
The dominant per-request cost in HawkAPI is already in C (granian, msgspec). On
80134
the bundled competitive benchmark suite (`benchmarks/competitive/runner.py`)
@@ -94,7 +148,7 @@ HAWKAPI_BUILD_MYPYC=1 uv pip install . --reinstall --no-build-isolation
94148
uv run python benchmarks/competitive/runner.py --framework hawkapi --duration 8
95149
```
96150

97-
## Caveats
151+
### Caveats
98152

99153
- **PyPy is unsupported.** mypyc emits CPython C extensions; PyPy users get the
100154
pure-Python build automatically.

docs/guide/security.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
HawkAPI provides authentication schemes that integrate with OpenAPI.
44

5+
!!! warning "Comparing credentials safely"
6+
All built-in schemes (`HTTPBasic`, `HTTPBearer`, `APIKey*`,
7+
`OAuth2PasswordBearer`) only *extract* credentials from the request.
8+
Comparing the extracted value against your stored secret is **your**
9+
responsibility. Always use a constant-time helper:
10+
11+
```python
12+
import secrets
13+
14+
if not secrets.compare_digest(creds.password, stored_hash):
15+
raise HTTPException(401, detail="Invalid credentials")
16+
```
17+
18+
A plain `==` comparison leaks timing information and lets an attacker
19+
discover the secret one byte at a time.
20+
21+
For threat-model, OWASP API Top 10 compliance map, and responsible-disclosure
22+
policy see:
23+
24+
- [`SECURITY.md`](https://github.com/ashimov/HawkAPI/blob/main/SECURITY.md) — disclosure policy
25+
- [`docs/security/threat-model.md`](../security/threat-model.md) — STRIDE per subsystem
26+
- [`docs/security/owasp-api-top10-2023.md`](../security/owasp-api-top10-2023.md) — compliance map
27+
- `hawkapi doctor app:app` — lint 18 production-readiness rules
28+
529
## HTTP Bearer
630

731
```python

0 commit comments

Comments
 (0)