fastapi-guard 7.2.2__tar.gz → 7.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/.mike.yml +3 -1
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/CHANGELOG.md +28 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/PKG-INFO +16 -1
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/README.md +15 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/middleware.py +55 -52
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/pyproject.toml +1 -1
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/.gitattributes +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/.gitignore +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/.python-version +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/.safety-project.ini +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/CODE_OF_CONDUCT.md +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/CONTRIBUTING.md +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/LICENSE +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/MANIFEST.in +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/SECURITY.md +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/context7.json +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/__init__.py +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/_middleware_state.py +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/adapters.py +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/lifespan.py +0 -0
- {fastapi_guard-7.2.2 → fastapi_guard-7.3.1}/guard/py.typed +0 -0
|
@@ -3,6 +3,34 @@ Release Notes
|
|
|
3
3
|
|
|
4
4
|
___
|
|
5
5
|
|
|
6
|
+
v7.3.1 (2026-07-29)
|
|
7
|
+
-------------------
|
|
8
|
+
|
|
9
|
+
Per-route checks now resolve through unbounded mount and router nesting (v7.3.1)
|
|
10
|
+
--------------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
- **Security** — Fixes [GHSA-f2vm-w8gq-h378](https://github.com/rennf93/fastapi-guard/security/advisories/GHSA-f2vm-w8gq-h378) (CWE-287, per-route authentication bypass, CVSS 7.4). `SecurityMiddleware._match_route()` capped its recursive walk of the route tree at a hardcoded depth of 8 and silently gave up beyond it, so an endpoint mounted 9 or more `Mount` levels deep never resolved, `request.state.guard_route_id` was never set, and guard-core's route-config resolver returned `None` — which every per-route check reads as "no policy to enforce". An endpoint protected by `@require_auth` was served to any unauthenticated client, and by the same path so were `@rate_limit`, `@require_headers`, `@require_referrer`, `@custom_validation`, `@time_window`, per-route IP restrictions, and request size / content-type limits. The arbitrary depth cap is replaced by cycle detection over the descent chain — the condition it was approximating — so nesting is now unbounded while a self-nesting router still terminates.
|
|
13
|
+
- **Security** — The same bypass reached a second, more common topology: routers nested two or more levels deep via `include_router`, previously recorded in 7.2.2 as "deeply-nested prefixed routers remain a known limitation". FastAPI's `_IncludedRouter` keeps its sub-routes un-prefixed and applies the combined include prefix only in `effective_candidates()`, so descending into `original_router.routes` compared a request path like `/r0/r1/secret` against a route registered as `/r1/secret` and matched nothing. Resolution now descends through `effective_candidates()` where available — falling back to the previous attribute walk on FastAPI versions without it — and unwraps the `_EffectiveRouteContext` proxies it yields. Per-route decorators consequently fire at any include depth, and also on `Mount`s and plain Starlette `Route`s registered on an included router, which `include_router(router, prefix=...)` over `router.mount(...)` had likewise left unresolved.
|
|
14
|
+
- **Added** — `SecurityMiddleware` now reports a failed route match by setting `request.state.guard_route_unresolved`, which is the fail-open half of the advisory: `get_route_config()` returns `None` both when a route carries no per-route config and when resolution failed, and every per-route check collapses those two states into "nothing to enforce". Skipping checks on an undecorated route is correct and unchanged; skipping them because resolution failed is not. With guard-core >= 3.7.0 and `SecurityConfig.route_resolution_strict=True`, an unresolved request is logged, emits a `route_unresolved` event, and is blocked with `500` instead of running the pipeline with no per-route config. It defaults to `False` because a failed match is indistinguishable from a request the app does not route, so enabling it also turns unserved paths into `500`s rather than `404`s. Older guard-core ignores the new state attribute.
|
|
15
|
+
- **Performance** — Traversal is bounded by marking each route and sub-route collection as visited per request scope rather than by a fixed depth, so a graph reaching the same collection along many paths is walked once instead of exponentially often. An app with two overlapping sibling mounts per level resolves a non-matching 24-level path in 0.2 ms, against 99 s for a depth-capped walk without the marking. The one graph shape that still recurses without bound manufactures fresh sub-route objects on every access, which is not constructible through public FastAPI or Starlette APIs; it raises `RecursionError` and surfaces as `500` rather than as a silently unchecked route.
|
|
16
|
+
- **Fixed** — `SecurityConfig.global_behavior_rules` never ran. `_process_response()` passed `process_behavioral_rules` to guard-core's response factory but never `process_global_behavioral_rules`, and the factory only evaluates global rules when that callback is supplied, so the branch was unreachable on every request. A rule such as `return_pattern` with `threshold=2` and `action="ban"` served six matching requests without ever banning. The callback is now wired to `BehavioralProcessor.process_global_return_rules`. Global rules additionally need guard-core >= 3.7.0, which fixes the companion defect that left the behavioural tracker unresolved on decorator-only setups and made `usage_monitor` and `return_monitor` inert too.
|
|
17
|
+
- **Tests** — Route resolution and end-to-end `@require_auth` enforcement are now covered at 12 nested `Mount` levels and at 12 nested prefixed routers, plus a `Mount` inside a prefixed included router, alongside the existing self-nesting-router termination test. The global-rule callback is asserted at the call site; the end-to-end banning behaviour it enables is covered in guard-core, since it cannot fire until 3.7.0 ships.
|
|
18
|
+
|
|
19
|
+
___
|
|
20
|
+
|
|
21
|
+
v7.3.0 (2026-07-15)
|
|
22
|
+
-------------------
|
|
23
|
+
|
|
24
|
+
Security pipeline assembly delegated to guard-core's check factory (v7.3.0)
|
|
25
|
+
---------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
- **Changed** — The security pipeline is now assembled by guard-core's `build_default_pipeline()`. New guard-core checks are picked up automatically; the middleware no longer hand-lists check classes.
|
|
28
|
+
- **Compatibility (behavior change from guard-core 3.5.0)** — Global IP whitelist/blacklist and country rules now apply on routes carrying per-route decorator config; previously any decorated route silently bypassed every global IP and country rule. A client outside a configured global `whitelist` now receives `403` on decorated routes that previously served it. A route-level `@ip_whitelist` now grants access only, not trust: the matched request is still rate-limited, user-agent-filtered, cloud-provider-checked, attack-scanned, and country-checked, while global `whitelist` membership still confers full trust. The IP and country aspects are evaluated independently — a route `@ip_whitelist` match does not bypass country rules; only a route `@whitelist_countries` match overrides the global country gate. See the guard-core 3.5.0 changelog for the full description and migration guidance.
|
|
29
|
+
- **Tests** — The decorator test suite now exercises the rate-limiting, user-agent, and suspicious-activity decorators through the enforced security pipeline using a non-whitelisted client, with explicit coverage for the new decorated-route `403` behavior and for a route `@ip_whitelist` client still being rate-limited. Previously these tests sent requests from a globally-whitelisted address, so the security pipeline short-circuited and the decorator logic was never exercised.
|
|
30
|
+
- **Requires** — `guard-core>=3.5.0` (declared as unconstrained `guard-core` in pyproject; documented here for upgrade guidance). The pipeline factory (`build_default_pipeline`) and the decorated-route IP/country enforcement this release relies on ship in guard-core 3.5.0; installing against an older guard-core fails at middleware initialization with an `ImportError`.
|
|
31
|
+
|
|
32
|
+
___
|
|
33
|
+
|
|
6
34
|
v7.2.2 (2026-07-01)
|
|
7
35
|
-------------------
|
|
8
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi_guard
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.3.1
|
|
4
4
|
Summary: Production-ready security middleware for FastAPI — IP filtering, rate limiting, penetration detection, and 20+ per-route security decorators.
|
|
5
5
|
Project-URL: Homepage, https://guard-core.com
|
|
6
6
|
Project-URL: Documentation, https://rennf93.github.io/fastapi-guard/latest/
|
|
@@ -271,6 +271,21 @@ Published on crates.io. **🚧 Placeholder crates — implementation in progress
|
|
|
271
271
|
| [rocket-guard-rs](https://github.com/rennf93/rocket-guard-rs) | Rocket adapter | [](https://crates.io/crates/rocket-guard-rs) |
|
|
272
272
|
| [tower-guard-rs](https://github.com/rennf93/tower-guard-rs) | Tower adapter | [](https://crates.io/crates/tower-guard-rs) |
|
|
273
273
|
|
|
274
|
+
### AI Coding Agents
|
|
275
|
+
|
|
276
|
+
| Package | Role | PyPI |
|
|
277
|
+
|---|---|---|
|
|
278
|
+
| [guard-core-mcp](https://github.com/rennf93/guard-core-mcp) | MCP server — config validation, docs search, detection sandbox | [](https://pypi.org/project/guard-core-mcp/) |
|
|
279
|
+
|
|
280
|
+
An MCP server that answers questions about FastAPI Guard from the version **installed in your project**, rather than from a model's memory of it. It validates a config against the real `SecurityConfig` model — catching silently-ignored typos like `redis_failopen` — looks up any field's type, default and description, searches the bundled docs, and runs a payload through the real detection engine to show whether it would be blocked and by which pattern.
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
uv add --dev guard-core-mcp
|
|
284
|
+
claude mcp add guard-core -- uv run guard-core-mcp
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Install it into the same environment as FastAPI Guard — it introspects what is actually installed there, so an isolated run (`uvx`) has nothing to read.
|
|
288
|
+
|
|
274
289
|
---
|
|
275
290
|
|
|
276
291
|
## Documentation
|
|
@@ -206,6 +206,21 @@ Published on crates.io. **🚧 Placeholder crates — implementation in progress
|
|
|
206
206
|
| [rocket-guard-rs](https://github.com/rennf93/rocket-guard-rs) | Rocket adapter | [](https://crates.io/crates/rocket-guard-rs) |
|
|
207
207
|
| [tower-guard-rs](https://github.com/rennf93/tower-guard-rs) | Tower adapter | [](https://crates.io/crates/tower-guard-rs) |
|
|
208
208
|
|
|
209
|
+
### AI Coding Agents
|
|
210
|
+
|
|
211
|
+
| Package | Role | PyPI |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| [guard-core-mcp](https://github.com/rennf93/guard-core-mcp) | MCP server — config validation, docs search, detection sandbox | [](https://pypi.org/project/guard-core-mcp/) |
|
|
214
|
+
|
|
215
|
+
An MCP server that answers questions about FastAPI Guard from the version **installed in your project**, rather than from a model's memory of it. It validates a config against the real `SecurityConfig` model — catching silently-ignored typos like `redis_failopen` — looks up any field's type, default and description, searches the bundled docs, and runs a payload through the real detection engine to show whether it would be blocked and by which pattern.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
uv add --dev guard-core-mcp
|
|
219
|
+
claude mcp add guard-core -- uv run guard-core-mcp
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Install it into the same environment as FastAPI Guard — it introspects what is actually installed there, so an isolated run (`uvx`) has nothing to read.
|
|
223
|
+
|
|
209
224
|
---
|
|
210
225
|
|
|
211
226
|
## Documentation
|
|
@@ -242,51 +242,13 @@ class SecurityMiddleware(BaseHTTPMiddleware):
|
|
|
242
242
|
return {"enabled": True, "degraded": self.agent_degraded, **handler_stats}
|
|
243
243
|
|
|
244
244
|
def _build_security_pipeline(self) -> None:
|
|
245
|
-
from guard_core.core.checks import
|
|
246
|
-
AuthenticationCheck,
|
|
247
|
-
CloudIpRefreshCheck,
|
|
248
|
-
CloudProviderCheck,
|
|
249
|
-
CustomRequestCheck,
|
|
250
|
-
CustomValidatorsCheck,
|
|
251
|
-
EmergencyModeCheck,
|
|
252
|
-
HttpsEnforcementCheck,
|
|
253
|
-
IpSecurityCheck,
|
|
254
|
-
RateLimitCheck,
|
|
255
|
-
ReferrerCheck,
|
|
256
|
-
RequestLoggingCheck,
|
|
257
|
-
RequestSizeContentCheck,
|
|
258
|
-
RequiredHeadersCheck,
|
|
259
|
-
RouteConfigCheck,
|
|
260
|
-
SecurityCheckPipeline,
|
|
261
|
-
SuspiciousActivityCheck,
|
|
262
|
-
TimeWindowCheck,
|
|
263
|
-
UserAgentCheck,
|
|
264
|
-
)
|
|
245
|
+
from guard_core.core.checks import build_default_pipeline
|
|
265
246
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
EmergencyModeCheck(self),
|
|
269
|
-
HttpsEnforcementCheck(self),
|
|
270
|
-
RequestLoggingCheck(self),
|
|
271
|
-
RequestSizeContentCheck(self),
|
|
272
|
-
RequiredHeadersCheck(self),
|
|
273
|
-
AuthenticationCheck(self),
|
|
274
|
-
ReferrerCheck(self),
|
|
275
|
-
CustomValidatorsCheck(self),
|
|
276
|
-
TimeWindowCheck(self),
|
|
277
|
-
CloudIpRefreshCheck(self),
|
|
278
|
-
IpSecurityCheck(self),
|
|
279
|
-
CloudProviderCheck(self),
|
|
280
|
-
UserAgentCheck(self),
|
|
281
|
-
RateLimitCheck(self),
|
|
282
|
-
SuspiciousActivityCheck(self),
|
|
283
|
-
CustomRequestCheck(self),
|
|
284
|
-
]
|
|
285
|
-
|
|
286
|
-
self.security_pipeline = SecurityCheckPipeline(checks)
|
|
247
|
+
pipeline = build_default_pipeline(self)
|
|
248
|
+
self.security_pipeline = pipeline
|
|
287
249
|
self.logger.info(
|
|
288
|
-
f"Security pipeline initialized with {len(
|
|
289
|
-
f"{
|
|
250
|
+
f"Security pipeline initialized with {len(pipeline)} "
|
|
251
|
+
f"checks: {pipeline.get_check_names()}"
|
|
290
252
|
)
|
|
291
253
|
|
|
292
254
|
def _configure_security_headers(self, config: SecurityConfig) -> None:
|
|
@@ -366,6 +328,9 @@ class SecurityMiddleware(BaseHTTPMiddleware):
|
|
|
366
328
|
response_time,
|
|
367
329
|
route_config,
|
|
368
330
|
process_behavioral_rules=self.behavioral_processor.process_return_rules,
|
|
331
|
+
process_global_behavioral_rules=(
|
|
332
|
+
self.behavioral_processor.process_global_return_rules
|
|
333
|
+
),
|
|
369
334
|
)
|
|
370
335
|
return unwrap_response(result)
|
|
371
336
|
|
|
@@ -377,30 +342,58 @@ class SecurityMiddleware(BaseHTTPMiddleware):
|
|
|
377
342
|
routes = getattr(app, "routes", None)
|
|
378
343
|
if not routes:
|
|
379
344
|
return None
|
|
380
|
-
return self._match_route(routes, request.scope,
|
|
345
|
+
return self._match_route(routes, request.scope, set())
|
|
381
346
|
|
|
382
|
-
def _match_route(self, routes: Any, scope: Any,
|
|
347
|
+
def _match_route(self, routes: Any, scope: Any, seen: set[Any]) -> Any:
|
|
383
348
|
# This middleware is a BaseHTTPMiddleware, so it runs before the router
|
|
384
349
|
# and scope["route"] is unset. Replicate Starlette's matching, descending
|
|
385
350
|
# into sub-routers / mounts (FastAPI's include_router inserts a wrapper
|
|
386
351
|
# route whose real routes are nested inside), so per-route decorator
|
|
387
352
|
# config resolves for path-parameter and include_router routes, not only
|
|
388
353
|
# routes flattened directly onto the app.
|
|
354
|
+
if not self._first_visit(seen, routes, scope):
|
|
355
|
+
return None
|
|
389
356
|
for r in routes:
|
|
390
357
|
matched, child = self._route_full_match(r, scope)
|
|
391
358
|
if not matched:
|
|
392
359
|
continue
|
|
393
|
-
|
|
360
|
+
r = self._unwrap_candidate(r)
|
|
361
|
+
if getattr(r, "endpoint", None) is not None:
|
|
394
362
|
return r
|
|
395
|
-
|
|
363
|
+
child_scope = {**scope, **child}
|
|
364
|
+
if not self._first_visit(seen, r, child_scope):
|
|
396
365
|
continue
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
366
|
+
sub_routes = self._sub_routes(r)
|
|
367
|
+
if not sub_routes:
|
|
368
|
+
return r
|
|
369
|
+
found = self._match_route(sub_routes, child_scope, seen)
|
|
400
370
|
if found is not None:
|
|
401
371
|
return found
|
|
402
372
|
return None
|
|
403
373
|
|
|
374
|
+
def _first_visit(self, seen: set[Any], node: Any, scope: Any) -> bool:
|
|
375
|
+
# Replaces a fixed depth cap, which silently abandoned the walk and left
|
|
376
|
+
# deeply nested routes unresolved. Marking the route and its sub-route
|
|
377
|
+
# collection stops mount cycles and keeps a graph that reaches the same
|
|
378
|
+
# collection by many paths linear rather than exponential. Ids are safe as
|
|
379
|
+
# keys: every collection walked is owned by a live app, router or route
|
|
380
|
+
# held on the call stack, so none can be freed and recycled mid-walk.
|
|
381
|
+
key = (id(node), scope.get("path"), scope.get("root_path"))
|
|
382
|
+
if key in seen:
|
|
383
|
+
return False
|
|
384
|
+
seen.add(key)
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
def _unwrap_candidate(self, route: Any) -> Any:
|
|
388
|
+
# FastAPI's include machinery matches through _EffectiveRouteContext proxies
|
|
389
|
+
# that hold the prefix-corrected route; that inner route is what carries the
|
|
390
|
+
# endpoint and the sub-routes, and its child scope is already applied here.
|
|
391
|
+
return (
|
|
392
|
+
getattr(route, "starlette_route", None)
|
|
393
|
+
or getattr(route, "original_route", None)
|
|
394
|
+
or route
|
|
395
|
+
)
|
|
396
|
+
|
|
404
397
|
def _route_full_match(self, route: Any, scope: Any) -> tuple[bool, Any]:
|
|
405
398
|
matches = getattr(route, "matches", None)
|
|
406
399
|
if matches is None:
|
|
@@ -412,6 +405,12 @@ class SecurityMiddleware(BaseHTTPMiddleware):
|
|
|
412
405
|
return match == Match.FULL, child
|
|
413
406
|
|
|
414
407
|
def _sub_routes(self, route: Any) -> Any:
|
|
408
|
+
# FastAPI's _IncludedRouter keeps sub-routes un-prefixed and only applies the
|
|
409
|
+
# combined include prefix in effective_candidates(); descending into
|
|
410
|
+
# original_router.routes instead resolves nothing past one nesting level.
|
|
411
|
+
candidates = getattr(route, "effective_candidates", None)
|
|
412
|
+
if callable(candidates):
|
|
413
|
+
return candidates()
|
|
415
414
|
included = getattr(route, "original_router", None)
|
|
416
415
|
mounted = getattr(route, "app", None)
|
|
417
416
|
return (
|
|
@@ -431,10 +430,14 @@ class SecurityMiddleware(BaseHTTPMiddleware):
|
|
|
431
430
|
guard_request.state.guard_decorator = app_decorator
|
|
432
431
|
|
|
433
432
|
route = self._resolve_route(request)
|
|
434
|
-
if not route
|
|
433
|
+
if not route:
|
|
434
|
+
guard_request.state.guard_route_unresolved = True
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
ep = getattr(route, "endpoint", None)
|
|
438
|
+
if ep is None:
|
|
435
439
|
return
|
|
436
440
|
|
|
437
|
-
ep = route.endpoint
|
|
438
441
|
if hasattr(ep, "_guard_route_id"):
|
|
439
442
|
guard_request.state.guard_route_id = ep._guard_route_id
|
|
440
443
|
if hasattr(ep, "__module__") and hasattr(ep, "__qualname__"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi_guard"
|
|
3
|
-
version = "7.
|
|
3
|
+
version = "7.3.1"
|
|
4
4
|
description = "Production-ready security middleware for FastAPI — IP filtering, rate limiting, penetration detection, and 20+ per-route security decorators."
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|