diff --git a/AGENTS.md b/AGENTS.md index fadc3ed..9c7e5d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,8 @@ Tornado is async, so this adapter imports `guard_core.*` directly (the Flask and ### Package Components - **`tornadoapi_guard/__init__.py`** - Re-exports items from `guard_core` so users can `from tornadoapi_guard import SecurityConfig, SecurityDecorator, ...` without knowing about guard-core. Also exports `SecurityMiddleware` and `SecurityHandler`, and the adapter classes `TornadoGuardRequest`, `TornadoGuardResponse`, `TornadoResponseFactory`, `apply_guard_response`, `unwrap_response`. -- **`tornadoapi_guard/middleware.py`** - `SecurityMiddleware`, the orchestrator. It builds the `SecurityCheckPipeline` from the config, runs pre-processing (bypass handling, route resolution, the 17 checks) and post-processing (metrics, logging), applies CORS/pre-flight headers, and exposes `set_decorator_handler` so the pipeline can be derived from registered route config. -- **`tornadoapi_guard/handler.py`** - `SecurityHandler(RequestHandler)`. Wrap handler classes that should be guarded: `prepare()` runs pre-flight headers, pre-processing, CORS preflight handling, and applies a blocking response via `apply_guard_response`; `on_finish()` schedules post-processing as a background task. +- **`tornadoapi_guard/middleware.py`** - `SecurityMiddleware`, the orchestrator. It builds the `SecurityCheckPipeline` from the config, runs pre-processing exactly like fastapi-guard's `dispatch` (CORS preflight, engine passthrough and route bypass handlers, route resolution, the 17 checks) and the post-processing response pass (return rules, metrics, security headers, CORS, modifier), and exposes `set_decorator_handler` so the pipeline can be derived from registered route config. +- **`tornadoapi_guard/handler.py`** - `SecurityHandler(RequestHandler)`. Wrap handler classes that should be guarded: `prepare()` runs pre-processing and writes any early response (blocked, preflight, bypass) via `apply_guard_response`; `finish()` is overridden to run the engine response pass before the response is flushed. - **`tornadoapi_guard/adapters.py`** - Protocol adapters bridging Tornado types to guard-core protocols: `TornadoGuardRequest` (wraps `RequestHandler` + `HTTPServerRequest`), `TornadoGuardResponse` (standalone response container), `TornadoResponseFactory`, plus `apply_guard_response()` (writes a guard response back through the handler) and `unwrap_response()`. ### Public API @@ -58,7 +58,7 @@ Tornado's architecture differs from FastAPI/Starlette: - `RequestHandler` is the main abstraction (not a separate request/response pair) - `RequestHandler.request` gives `HTTPServerRequest` - `RequestHandler.prepare()` is the async hook for pre-processing (security pipeline) -- `RequestHandler.on_finish()` is the post-processing hook (metrics, logging) +- `RequestHandler.finish()` is where the response is flushed, so the adapter overrides it to run the response pass first - No built-in response object; responses are written via `handler.write()` / `handler.finish()` - `request.remote_ip` for client IP - `request.headers` for headers (`HTTPHeaders`, case-insensitive) @@ -198,7 +198,7 @@ tornadoapi-guard/ ├── tornadoapi_guard/ # Adapter package (thin layer over guard-core) │ ├── __init__.py # Re-exports from guard_core + SecurityMiddleware/SecurityHandler │ ├── middleware.py # SecurityMiddleware (pipeline orchestration) -│ ├── handler.py # SecurityHandler(RequestHandler) (prepare/on_finish hooks) +│ ├── handler.py # SecurityHandler(RequestHandler) (prepare/finish hooks) │ ├── adapters.py # Tornado request/response protocol adapters │ ├── .agents/skills/tornadoapi-guard/ # Package skill (SKILL.md) │ └── py.typed # PEP 561 marker diff --git a/CLAUDE.md b/CLAUDE.md index fadc3ed..9c7e5d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,8 @@ Tornado is async, so this adapter imports `guard_core.*` directly (the Flask and ### Package Components - **`tornadoapi_guard/__init__.py`** - Re-exports items from `guard_core` so users can `from tornadoapi_guard import SecurityConfig, SecurityDecorator, ...` without knowing about guard-core. Also exports `SecurityMiddleware` and `SecurityHandler`, and the adapter classes `TornadoGuardRequest`, `TornadoGuardResponse`, `TornadoResponseFactory`, `apply_guard_response`, `unwrap_response`. -- **`tornadoapi_guard/middleware.py`** - `SecurityMiddleware`, the orchestrator. It builds the `SecurityCheckPipeline` from the config, runs pre-processing (bypass handling, route resolution, the 17 checks) and post-processing (metrics, logging), applies CORS/pre-flight headers, and exposes `set_decorator_handler` so the pipeline can be derived from registered route config. -- **`tornadoapi_guard/handler.py`** - `SecurityHandler(RequestHandler)`. Wrap handler classes that should be guarded: `prepare()` runs pre-flight headers, pre-processing, CORS preflight handling, and applies a blocking response via `apply_guard_response`; `on_finish()` schedules post-processing as a background task. +- **`tornadoapi_guard/middleware.py`** - `SecurityMiddleware`, the orchestrator. It builds the `SecurityCheckPipeline` from the config, runs pre-processing exactly like fastapi-guard's `dispatch` (CORS preflight, engine passthrough and route bypass handlers, route resolution, the 17 checks) and the post-processing response pass (return rules, metrics, security headers, CORS, modifier), and exposes `set_decorator_handler` so the pipeline can be derived from registered route config. +- **`tornadoapi_guard/handler.py`** - `SecurityHandler(RequestHandler)`. Wrap handler classes that should be guarded: `prepare()` runs pre-processing and writes any early response (blocked, preflight, bypass) via `apply_guard_response`; `finish()` is overridden to run the engine response pass before the response is flushed. - **`tornadoapi_guard/adapters.py`** - Protocol adapters bridging Tornado types to guard-core protocols: `TornadoGuardRequest` (wraps `RequestHandler` + `HTTPServerRequest`), `TornadoGuardResponse` (standalone response container), `TornadoResponseFactory`, plus `apply_guard_response()` (writes a guard response back through the handler) and `unwrap_response()`. ### Public API @@ -58,7 +58,7 @@ Tornado's architecture differs from FastAPI/Starlette: - `RequestHandler` is the main abstraction (not a separate request/response pair) - `RequestHandler.request` gives `HTTPServerRequest` - `RequestHandler.prepare()` is the async hook for pre-processing (security pipeline) -- `RequestHandler.on_finish()` is the post-processing hook (metrics, logging) +- `RequestHandler.finish()` is where the response is flushed, so the adapter overrides it to run the response pass first - No built-in response object; responses are written via `handler.write()` / `handler.finish()` - `request.remote_ip` for client IP - `request.headers` for headers (`HTTPHeaders`, case-insensitive) @@ -198,7 +198,7 @@ tornadoapi-guard/ ├── tornadoapi_guard/ # Adapter package (thin layer over guard-core) │ ├── __init__.py # Re-exports from guard_core + SecurityMiddleware/SecurityHandler │ ├── middleware.py # SecurityMiddleware (pipeline orchestration) -│ ├── handler.py # SecurityHandler(RequestHandler) (prepare/on_finish hooks) +│ ├── handler.py # SecurityHandler(RequestHandler) (prepare/finish hooks) │ ├── adapters.py # Tornado request/response protocol adapters │ ├── .agents/skills/tornadoapi-guard/ # Package skill (SKILL.md) │ └── py.typed # PEP 561 marker diff --git a/docs/api/adapters.md b/docs/api/adapters.md index b9bb721..ef2c758 100644 --- a/docs/api/adapters.md +++ b/docs/api/adapters.md @@ -114,7 +114,7 @@ Snapshots the current state of a `RequestHandler` into a `TornadoGuardResponse`. - `handler._headers` (Tornado's internal `HTTPHeaders` container) for the accumulated response headers. - `handler._write_buffer` for the accumulated response body. -Used by `SecurityMiddleware.run_post_processing` to build a response object from a handler that has already written its reply, so that behavioral return rules and metrics collectors can see the actual status / headers / body that went on the wire. +Used by `SecurityMiddleware.run_post_processing` to build a response object from a handler that has already written its reply, so that behavioral return rules and metrics collectors can see the actual status / headers / body before they are flushed to the client. `SecurityHandler.finish()` runs the response pass at that point, so any change the pass makes (security headers, CORS headers, `custom_response_modifier`) is written back onto the handler and reaches the client. ___ @@ -179,7 +179,7 @@ Writing Custom Hooks `SecurityConfig` accepts two user-provided callables: - `custom_request_check(request)` — runs at the end of the security pipeline. Return a `GuardResponse` to short-circuit the request, or `None` to let it through. -- `custom_response_modifier(response)` — runs during post-processing. Receives the built response and may mutate its headers (or construct a new response) before the middleware hands it back. +- `custom_response_modifier(response)` - runs during the response pass in `SecurityHandler.finish()`, before the response is flushed. Receives the response built from the handler (and the pipeline's own blocking responses) and may mutate its headers (or construct a new response); the status, header and body changes reach the client. Use `TornadoGuardResponse` for return values so that `SecurityMiddleware` can apply headers, CORS, and the custom modifier uniformly. The pattern below mirrors `examples/simple_app/main.py`: @@ -227,7 +227,7 @@ ___ Per-handler State ----------------- -`TornadoGuardRequest.state` returns a `SimpleNamespace` that the middleware uses to stash per-request data: the guard request itself, the resolved route config, the extracted client IP, and the request start time. Behavioral hooks, post-processing, and custom validators read from this namespace. +`TornadoGuardRequest.state` returns a `SimpleNamespace` that the middleware uses to stash per-request data: the guard request itself, the resolved route config, the extracted client IP, and the request start time. Behavioral hooks, the response pass, and custom validators read from this namespace. The storage is backed by a module-level `WeakKeyDictionary` keyed by the handler instance: diff --git a/docs/api/security-handler.md b/docs/api/security-handler.md index 7ba922e..440b452 100644 --- a/docs/api/security-handler.md +++ b/docs/api/security-handler.md @@ -2,7 +2,7 @@ title: SecurityHandler API - TornadoAPI Guard description: Complete API reference for TornadoAPI Guard's SecurityHandler base class and its lifecycle hooks -keywords: tornado, security, middleware, request handler, lifecycle hooks, prepare, on_finish +keywords: tornado, security, middleware, request handler, lifecycle hooks, prepare, finish --- SecurityHandler @@ -11,7 +11,7 @@ SecurityHandler The `SecurityHandler` class is the Tornado-specific base class that every handler inherits from in order to participate in the `tornadoapi-guard` security pipeline. !!! info "Why a base handler instead of middleware?" - Tornado does not have an application-level middleware chain like ASGI frameworks (FastAPI, Starlette). There is no `app.add_middleware(...)` equivalent. Instead, Tornado exposes pre-/post-processing hooks on `tornado.web.RequestHandler` (`prepare`, `on_finish`). `tornadoapi-guard` plugs into these hooks through a base class: subclassing `SecurityHandler` is what "attaches" the security pipeline to a route. + Tornado does not have an application-level middleware chain like ASGI frameworks (FastAPI, Starlette). There is no `app.add_middleware(...)` equivalent. Instead, Tornado exposes hooks on `tornado.web.RequestHandler` (`prepare`, `finish`). `tornadoapi-guard` plugs into these hooks through a base class: subclassing `SecurityHandler` is what "attaches" the security pipeline to a route. ___ @@ -24,7 +24,7 @@ from tornado.web import RequestHandler class SecurityHandler(RequestHandler): async def prepare(self) -> None: ... - def on_finish(self) -> None: ... + def finish(self, chunk=None) -> Future[None]: ... def _get_security_middleware(self) -> SecurityMiddleware | None: ... ``` @@ -41,43 +41,36 @@ async def prepare(self) -> None: if middleware is None: return - await middleware.apply_pre_flight_headers(self) - - blocking = await middleware.run_pre_processing(self) - if blocking is not None: - apply_guard_response(self, blocking) - return + early = await middleware.run_pre_processing(self) + if early is not None: + apply_guard_response(self, early) ``` `prepare()` runs **before** the HTTP method (`get`, `post`, etc.) is dispatched. `SecurityHandler` overrides it to: 1. Look up the middleware via `self.application.settings["security_middleware"]`. -2. Call `middleware.apply_pre_flight_headers(self)` to set security and CORS headers on the response (so headers are present even if the handler later writes an error or short-circuits). -3. Call `middleware.run_pre_processing(self)` to execute the 17-check security pipeline (route config, emergency mode, HTTPS enforcement, request logging, request size/content, required headers, authentication, referrer, custom validators, time window, cloud IP refresh, IP security, cloud provider, user agent, rate limit, suspicious activity, and custom request check). -4. If the pipeline returns a blocking response, `apply_guard_response()` writes the status, headers, and body back through the handler (via `clear`, `set_status`, `set_header`, `write`, `finish`). The actual HTTP method is **not** invoked. +2. Call `middleware.run_pre_processing(self)`, which mirrors fastapi-guard's `dispatch`: the CORS preflight, the engine passthrough handling (excluded paths stay scoped so the checks that apply to them still run, and a request with no client address gets the `fail_secure` 403), the route all-checks bypass (the real handler method is called and the response modifier applied), and the 17-check security pipeline (route config, emergency mode, HTTPS enforcement, request logging, request size/content, required headers, authentication, referrer, custom validators, time window, cloud IP refresh, IP security, cloud provider, user agent, rate limit, suspicious activity, and custom request check). +3. If any of those produces a response (blocked, preflight, bypass, or the Redis-unavailable 503), `apply_guard_response()` writes the status, headers, and body back through the handler (via `clear`, `set_status`, `set_header`, `write`, `finish`) with the CORS headers attached. The actual HTTP method is **not** invoked again, and this response does not go through the response pass below, so it emits no request metrics. Because `prepare()` is `async`, the pipeline can perform async work (Redis lookups, IPInfo API calls, agent event publishing) without blocking the Tornado IOLoop. ___ -Lifecycle Hook: `on_finish` ---------------------------- +Response Pass: `finish` +----------------------- ```python -def on_finish(self) -> None: - middleware = self._get_security_middleware() - if middleware is None: - return - asyncio.create_task(middleware.run_post_processing(self)) +def finish(self, chunk=None) -> Future[None]: + ... ``` -`on_finish()` runs **after** the response has been sent to the client. `SecurityHandler` overrides it to schedule `middleware.run_post_processing(self)` via `asyncio.create_task`. Post-processing includes: +When the request proceeds to your handler method, `SecurityHandler` overrides `finish()` so the engine response pass runs on the outgoing response **before it is flushed**, exactly like fastapi-guard's `_process_response`. `finish()` returns a future (like Tornado's own), so `await self.finish()` and a bare `self.finish()` both work, and a second call (for example Tornado's auto-finish) returns the same pending future. The pass: -- Behavioral return-rule processing (for example, "ban after N `404` responses in 60 s"). -- Metrics emission (response time, status code, route). -- Event publishing to the Guard Agent (if configured). +- Processes route and global behavioral return rules, including body-based patterns, because the buffered body is visible before the flush. +- Collects request metrics (response time, status code). +- Applies the security headers, the CORS headers, and `custom_response_modifier`. Header, status and body changes made by the modifier reach the client. -Running post-processing as a fire-and-forget task keeps the response path fast: the client sees the response as soon as the handler finishes, and book-keeping happens in the background. +If the handler already flushed part of the response (`flush()` before `finish()`), the headers are on the wire, so the pass still observes the response (metrics and return rules over what is buffered) but cannot change what was sent. If the pass itself raises, the error is logged and the response is finished unmodified. ___ @@ -92,7 +85,7 @@ def _get_security_middleware(self) -> SecurityMiddleware | None: return None ``` -Internal lookup. Returns the `SecurityMiddleware` instance stored in `application.settings["security_middleware"]`, or `None` if no middleware is configured. When `None` is returned, both `prepare()` and `on_finish()` short-circuit — see [Degraded mode](#degraded-mode) below. +Internal lookup. Returns the `SecurityMiddleware` instance stored in `application.settings["security_middleware"]`, or `None` if no middleware is configured. When `None` is returned, both `prepare()` and `finish()` short-circuit - see [Degraded mode](#degraded-mode) below. ___ @@ -171,12 +164,9 @@ ___ Interaction with `set_default_headers` -------------------------------------- -`tornadoapi-guard` does **not** override `set_default_headers` on `SecurityHandler`. Security headers are applied in `prepare()` via `apply_pre_flight_headers` instead, for one reason: the `SecurityHeadersManager.get_headers()` method is asynchronous (it may read configuration from Redis), and Tornado's `set_default_headers` is **synchronous**. An async operation cannot complete inside a sync hook. - -This has two practical consequences: +`tornadoapi-guard` does **not** override `set_default_headers` on `SecurityHandler`. Security headers are applied by the engine response pass in `finish()`, because the `SecurityHeadersManager.get_headers()` method is asynchronous (it may read configuration from Redis) and Tornado's `set_default_headers` is **synchronous**. -1. You are free to override `set_default_headers` on your own handlers to add static headers (for example, `X-Service-Name`). The headers you set there are applied first, and `apply_pre_flight_headers` then adds (or overrides) the guard-managed security and CORS headers on top. -2. The guard-managed headers are attached even when the pipeline returns a blocking response, because `apply_pre_flight_headers` runs **before** `run_pre_processing`. A client that is rate-limited will still receive the correct `Content-Security-Policy`, `X-Frame-Options`, and CORS headers on the `429` response. +You are free to override `set_default_headers` on your own handlers to add static headers (for example, `X-Service-Name`). The headers you set there are applied first, and the response pass then adds (or overrides) the guard-managed security and CORS headers on top. Like fastapi-guard, responses produced by the pipeline itself (a `429` for a rate-limited client, for example) carry the CORS headers but not the security headers, which are only added to responses that came out of your handler. ___ @@ -186,7 +176,7 @@ Degraded Mode If a handler inherits from `SecurityHandler` but the `Application` does **not** have a `security_middleware` entry in its settings (for example, in a unit test, a minimal sandbox, or during local development without the full middleware stack), `_get_security_middleware()` returns `None` and both hooks short-circuit: - `prepare()` returns immediately — no headers are injected, no pipeline runs. -- `on_finish()` returns immediately — no post-processing is scheduled. +- `finish()` finishes the response immediately - no response pass runs. In this degraded state `SecurityHandler` behaves exactly like a plain `tornado.web.RequestHandler`. This is intentional: it lets you write test fixtures that instantiate handlers without having to stand up a full `SecurityMiddleware` and its Redis/agent dependencies. Just remember that **no security checks are being performed** when the middleware is absent. diff --git a/docs/api/security-middleware.md b/docs/api/security-middleware.md index 34c267a..2eef81e 100644 --- a/docs/api/security-middleware.md +++ b/docs/api/security-middleware.md @@ -8,7 +8,7 @@ keywords: security middleware, tornado middleware, api security, middleware conf SecurityMiddleware ================== -`SecurityMiddleware` is the core orchestration object of TornadoAPI Guard. It owns the security pipeline, the event bus, the metrics collector, and the response factory. Unlike FastAPI's `BaseHTTPMiddleware`, Tornado has no built-in `call_next` mechanism, so the middleware exposes its lifecycle as **explicit methods** that the `SecurityHandler` base class calls from `prepare()` (before the handler method runs) and `on_finish()` (after the response is sent). You never register it on the router directly — instead you pass the instance to `tornado.web.Application` as the `security_middleware` setting. +`SecurityMiddleware` is the core orchestration object of TornadoAPI Guard. It owns the security pipeline, the event bus, the metrics collector, and the response factory. Unlike FastAPI's `BaseHTTPMiddleware`, Tornado has no built-in `call_next` mechanism, so the middleware exposes its lifecycle as **explicit methods** that the `SecurityHandler` base class calls from `prepare()` (before the handler method runs) and `finish()` (before the response is flushed). You never register it on the router directly - instead you pass the instance to `tornado.web.Application` as the `security_middleware` setting. See also the [`SecurityHandler` API](security-handler.md) reference for the base class that invokes the middleware. @@ -44,48 +44,30 @@ RequestHandler.prepare() ↓ SecurityHandler.prepare() ↓ -1. SecurityMiddleware.apply_pre_flight_headers(handler) - └─ Writes configured security headers + CORS headers to the handler - ↓ -2. SecurityMiddleware.run_pre_processing(handler) - ├─ Build TornadoGuardRequest from handler.request - ├─ Populate request state (route config, client IP, start time) - ├─ Check passthrough / excluded paths - ├─ SecurityCheckPipeline.execute() - │ ├─ RouteConfigCheck - │ ├─ EmergencyModeCheck - │ ├─ HttpsEnforcementCheck - │ ├─ RequestLoggingCheck - │ ├─ RequestSizeContentCheck - │ ├─ RequiredHeadersCheck - │ ├─ AuthenticationCheck - │ ├─ ReferrerCheck - │ ├─ CustomValidatorsCheck - │ ├─ TimeWindowCheck - │ ├─ CloudIpRefreshCheck - │ ├─ IpSecurityCheck - │ ├─ CloudProviderCheck - │ ├─ UserAgentCheck - │ ├─ RateLimitCheck - │ ├─ SuspiciousActivityCheck - │ └─ CustomRequestCheck - ├─ If any check returns a blocking response: - │ └─ Apply security headers, CORS headers, custom modifier → return +1. SecurityMiddleware.run_pre_processing(handler) + ├─ Initialize handlers on first use (Redis unavailable → 503 + Retry-After) + ├─ Build TornadoGuardRequest, populate request state (start time, route id) + ├─ CORS preflight (pipeline first, then the preflight response) + ├─ Engine BypassHandler.handle_passthrough + │ ├─ Excluded path → scoped (pipeline runs only the checks enforced there) + │ └─ No client address → fail_secure 403 + ├─ Route config + engine BypassHandler.handle_security_bypass + │ └─ "all" bypass → calls the real handler method, applies the modifier + ├─ SecurityCheckPipeline.execute() (17 checks) └─ Process behavioral "usage" rules + ↓ (any response above is written by apply_guard_response, CORS attached, + ↓ and skips step 3) +2. Handler method runs (get / post / put / ...) ↓ -3. Handler method runs (get / post / put / ...) - ↓ -RequestHandler.on_finish() - ↓ -SecurityHandler.on_finish() +SecurityHandler.finish() ↓ -4. SecurityMiddleware.run_post_processing(handler) - ├─ Reconstruct response from handler state - ├─ Collect metrics and emit events - └─ Process behavioral "return" rules +3. SecurityMiddleware.run_post_processing(handler) (before the flush) + ├─ Route and global behavioral return rules + ├─ Collect request metrics + └─ Security headers, CORS headers, custom_response_modifier, written back ``` -The 17-check security pipeline is identical to the one in fastapi-guard since it lives in `guard_core`. What differs is **where** the pipeline is invoked: the Tornado adapter calls it from `SecurityHandler.prepare()` rather than from a `dispatch` coroutine. +The 17-check security pipeline is identical to the one in fastapi-guard since it lives in `guard_core`. What differs is **where** the pipeline is invoked: the Tornado adapter calls it from `SecurityHandler.prepare()` and runs the response pass from `SecurityHandler.finish()`, rather than from a `dispatch` coroutine. Core Components --------------- @@ -170,12 +152,14 @@ async def run_pre_processing( self, handler: RequestHandler ) -> GuardResponse | None: """ - Run the full security pipeline for an incoming request. + Run the engine request flow for an incoming request. Called automatically by SecurityHandler.prepare(). Returns a - GuardResponse when any check blocks the request (in which case - SecurityHandler writes the blocking response back through the - handler), or None when the request may proceed. + GuardResponse when the request is answered without reaching the + handler method (blocked, CORS preflight, route bypass, fail_secure + 403 for a missing client address, Redis unavailable 503), in which + case SecurityHandler writes it back through the handler, or None + when the request may proceed to the handler method. Args: handler: The current Tornado RequestHandler instance. @@ -188,24 +172,14 @@ run_post_processing ```python async def run_post_processing(self, handler: RequestHandler) -> None: """ - Run post-processing hooks after the handler method has finished. - - Called automatically by SecurityHandler.on_finish(). Performs: - - Metrics collection - - Security event emission - - Behavioral return-pattern processing - """ -``` - -apply_pre_flight_headers ------------------------- - -```python -async def apply_pre_flight_headers(self, handler: RequestHandler) -> None: - """ - Write the configured security headers (CSP, HSTS, frame options, etc.) - and — if the request carries an Origin header — CORS headers onto the - handler before the pipeline runs. + Run the engine response pass on the outgoing response. + + Called automatically by SecurityHandler.finish() before the response + is flushed. Performs: + - Route and global behavioral return-pattern processing + - Request metrics collection + - Security headers, CORS headers and custom_response_modifier, + applied back onto the handler """ ``` @@ -347,6 +321,6 @@ ___ See Also -------- -- [SecurityHandler](security-handler.md) - Base class that invokes the middleware from `prepare` and `on_finish` +- [SecurityHandler](security-handler.md) - Base class that invokes the middleware from `prepare` and `finish` - [Decorators](decorators.md) - Route-level security decorators - [API Overview](overview.md) - Complete API reference diff --git a/docs/tutorial/configuration/cors.md b/docs/tutorial/configuration/cors.md index f02c58f..30dbb40 100644 --- a/docs/tutorial/configuration/cors.md +++ b/docs/tutorial/configuration/cors.md @@ -8,7 +8,7 @@ keywords: tornado cors, cors configuration, api security, cross origin resource CORS Configuration ================== -TornadoAPI Guard provides comprehensive CORS (Cross-Origin Resource Sharing) configuration through the `SecurityConfig` model. Unlike frameworks that ship with a standalone CORS middleware, the Tornado adapter applies CORS headers directly from the handler pipeline: whenever a request carries an `Origin` header and `enable_cors=True` is set on the config, `SecurityHandler.apply_pre_flight_headers()` calls `security_headers_manager.get_cors_headers(origin)` and writes the resulting headers onto the response. +TornadoAPI Guard provides comprehensive CORS (Cross-Origin Resource Sharing) configuration through the `SecurityConfig` model. Unlike frameworks that ship with a standalone CORS middleware, the Tornado adapter applies CORS headers directly from the handler pipeline: whenever `enable_cors=True` is set on the config, the CORS preflight is answered from `SecurityHandler.prepare()`, and the engine response pass in `SecurityHandler.finish()` writes the CORS headers onto the response (blocked responses carry them too). The generated headers include: diff --git a/docs/tutorial/configuration/security-config.md b/docs/tutorial/configuration/security-config.md index 6547143..d46d35c 100644 --- a/docs/tutorial/configuration/security-config.md +++ b/docs/tutorial/configuration/security-config.md @@ -233,7 +233,7 @@ Sub-field reference: | `permissions_policy` | str | `"geolocation=(), microphone=(), camera=()"` | `Permissions-Policy` | | `custom` | dict[str, str] \| None | None | Additional custom headers applied to every response | -Headers are applied automatically by `SecurityHandler.prepare()` via `SecurityMiddleware.apply_pre_flight_headers()` — no extra wiring required. +Headers are applied automatically by the engine response pass in `SecurityHandler.finish()` - no extra wiring required. ___ diff --git a/docs/tutorial/examples/example-app.md b/docs/tutorial/examples/example-app.md index c6eaa0a..6a2bae9 100644 --- a/docs/tutorial/examples/example-app.md +++ b/docs/tutorial/examples/example-app.md @@ -61,7 +61,7 @@ Architecture Overview The example is built around three objects that every TornadoAPI Guard application needs: - **`SecurityMiddleware`** — holds the security pipeline, the metrics collector, and the event bus. Instantiated once at module level and then `await security_middleware.initialize()` is called in `main()` before the server starts. -- **`SecurityHandler`** — base class that every route inherits from (directly or via a small project-specific subclass like the `JSONHandler` in the example). `SecurityHandler.prepare()` runs the full security pipeline before the handler method, and `SecurityHandler.on_finish()` runs post-processing hooks. +- **`SecurityHandler`** - base class that every route inherits from (directly or via a small project-specific subclass like the `JSONHandler` in the example). `SecurityHandler.prepare()` runs the full security pipeline before the handler method, and `SecurityHandler.finish()` runs the response pass (metrics, return rules, headers, modifier) before the response is flushed. - **`SecurityDecorator`** — route-level decorator set. Instantiated once at module level and registered with the middleware via `security_middleware.set_decorator_handler(guard_decorator)`. Applied to handler methods (`get`, `post`, `put`, ...), never to the class. The example also defines a project-specific `JSONHandler(SecurityHandler)` subclass that adds `write_json` / `write_error` helpers — a useful pattern for any production app. diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md index 197c938..e3e613b 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/tutorial/first-steps.md @@ -13,7 +13,7 @@ Let's start with a simple example that shows how to add TornadoAPI Guard to your Create a Tornado application ---------------------------- -First, create a new Tornado application. TornadoAPI Guard integrates via two objects: the `SecurityMiddleware` (which holds the security pipeline) and the `SecurityHandler` base class (which wires the pipeline into Tornado's `prepare`/`on_finish` hooks). Every handler that should be protected must inherit from `SecurityHandler`. +First, create a new Tornado application. TornadoAPI Guard integrates via two objects: the `SecurityMiddleware` (which holds the security pipeline) and the `SecurityHandler` base class (which wires the pipeline into Tornado's `prepare`/`finish` hooks). Every handler that should be protected must inherit from `SecurityHandler`. ```python import asyncio @@ -85,7 +85,7 @@ ___ Inherit from SecurityHandler ---------------------------- -Every request handler that should be protected must subclass `SecurityHandler`. The base class overrides `prepare()` to run the full security pipeline (IP checks, rate limiting, penetration detection, etc.) before your method runs, and overrides `on_finish()` to run post-processing hooks (metrics, behavioral return rules, logging). If you inherit from `tornado.web.RequestHandler` directly, no security checks will run for that route. +Every request handler that should be protected must subclass `SecurityHandler`. The base class overrides `prepare()` to run the full security pipeline (IP checks, rate limiting, penetration detection, etc.) before your method runs, and overrides `finish()` to run the response pass (metrics, behavioral return rules, security headers, CORS and your `custom_response_modifier`) before the response is flushed. If you inherit from `tornado.web.RequestHandler` directly, no security checks will run for that route. ```python from tornadoapi_guard import SecurityHandler diff --git a/pyproject.toml b/pyproject.toml index 8b0ce16..a5f1cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ keywords = [ ] dependencies = [ "tornado", - "guard-core>=4.0.0", + "guard-core>=4.1.0", ] [project.urls] diff --git a/tests/test_adapters.py b/tests/test_adapters.py index b3758d8..e25b6f2 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -128,6 +128,13 @@ async def test_response_properties() -> None: assert response.body == b"ok" +async def test_response_read_body_prefix() -> None: + response = TornadoGuardResponse(status_code=200, body=b"abcdef") + assert await response.read_body_prefix(3) == b"abc" + assert await response.read_body_prefix(0) == b"" + assert await TornadoGuardResponse(status_code=204).read_body_prefix(5) == b"" + + async def test_response_headers_mutable() -> None: response = TornadoGuardResponse(status_code=200) response.headers["X-New"] = "v" diff --git a/tests/test_engine_parity.py b/tests/test_engine_parity.py new file mode 100644 index 0000000..af3bfb6 --- /dev/null +++ b/tests/test_engine_parity.py @@ -0,0 +1,453 @@ +"""Engine parity: the adapter routes through the engine exactly like fastapi-guard. + +Real Tornado HTTP server and client, real engine bypass handler and response +factory. The metrics collector is spied on (it still runs) to prove which +responses reach the engine response pass. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +import pytest +import tornado.httpserver +import tornado.netutil +import tornado.web +from guard_core.models import SecurityConfig +from guard_core.protocols.response_protocol import GuardResponse +from tornado.httpclient import AsyncHTTPClient, HTTPResponse + +from tornadoapi_guard import ( + SecurityDecorator, + SecurityHandler, + SecurityMiddleware, + TornadoGuardResponse, +) + +security = SecurityDecorator(SecurityConfig(behavior_scan_response_body=True)) +XFF_BLOCKED = {"X-Forwarded-For": "10.9.9.9"} +XFF_REMOTE = {"X-Forwarded-For": "203.0.113.9"} + + +class HealthHandler(SecurityHandler): + async def get(self) -> None: + self.write({"message": "ok"}) + + +class OpenHandler(SecurityHandler): + async def get(self) -> None: + self.write({"message": "Echo response"}) + + +class NoClientHandler(SecurityHandler): + async def prepare(self) -> None: + self.request.remote_ip = None # type: ignore[assignment] + await super().prepare() + + async def get(self) -> None: + self.write({"message": "Echo response"}) + + +class NoClientHealthHandler(NoClientHandler): + pass + + +class BypassedHandler(SecurityHandler): + @security.bypass(["all"]) + async def get(self) -> None: + self.write({"message": "real view"}) + self.finish() + + +class RouteRuleHandler(SecurityHandler): + @security.return_monitor( + "json:message==Echo response", max_occurrences=2, window=60, action="ban" + ) + async def get(self) -> None: + self.write({"message": "Echo response"}) + + +class ExplicitFinishHandler(SecurityHandler): + async def get(self) -> None: + self.write({"message": "explicit"}) + await self.finish() + + +class NonAwaitedFinishHandler(SecurityHandler): + def get(self) -> None: + self.write({"message": "sync finish"}) + self.finish() + + +class SyncBypassedHandler(SecurityHandler): + @security.bypass(["all"]) + def get(self) -> None: + self.finish("sync bypassed") + + +class StreamingHandler(SecurityHandler): + async def get(self) -> None: + self.write("a") + await self.flush() + self.write("b") + + +class ErrorHandler(SecurityHandler): + async def get(self) -> None: + raise tornado.web.HTTPError(500) + + +@dataclass +class Server: + port: int + middleware: SecurityMiddleware + metrics: list[str] = field(default_factory=list) + + async def get( + self, path: str, headers: dict[str, str] | None = None, method: str = "GET" + ) -> HTTPResponse: + return await AsyncHTTPClient().fetch( + f"http://127.0.0.1:{self.port}{path}", + method=method, + headers=headers, + raise_error=False, + allow_nonstandard_methods=True, + ) + + +ServerFactory = Callable[..., Awaitable[Server]] + + +@pytest.fixture +async def serve() -> AsyncGenerator[ServerFactory, None]: + started: list[tuple[tornado.httpserver.HTTPServer, SecurityMiddleware]] = [] + + async def _serve(**overrides: Any) -> Server: + base: dict[str, Any] = { + "enable_redis": False, + "enable_penetration_detection": False, + "whitelist": None, + "blacklist": ["10.9.9.9"], + "trusted_proxies": ["127.0.0.1"], + "exclude_paths": ["/health"], + "security_headers": {"enabled": True}, + } + base.update(overrides) + middleware = SecurityMiddleware(config=SecurityConfig(**base)) + middleware.set_decorator_handler(security) + app = tornado.web.Application( + [ + (r"/health", HealthHandler), + (r"/open", OpenHandler), + (r"/noclient", NoClientHandler), + (r"/noclient-health", NoClientHealthHandler), + (r"/bypassed", BypassedHandler), + (r"/route-rule", RouteRuleHandler), + (r"/explicit", ExplicitFinishHandler), + (r"/sync-finish", NonAwaitedFinishHandler), + (r"/sync-bypassed", SyncBypassedHandler), + (r"/streaming", StreamingHandler), + (r"/error", ErrorHandler), + ], + security_middleware=middleware, + guard_decorator=security, + ) + metrics: list[str] = [] + real = middleware.metrics_collector.collect_request_metrics + + async def spy(request: Any, response_time: float, status_code: int) -> None: + metrics.append(request.url_path) + await real(request, response_time, status_code) + + middleware.metrics_collector.collect_request_metrics = spy # type: ignore[method-assign] + await middleware.initialize() + sockets = tornado.netutil.bind_sockets(0, "127.0.0.1") + server = tornado.httpserver.HTTPServer(app) + server.add_sockets(sockets) + started.append((server, middleware)) + return Server(sockets[0].getsockname()[1], middleware, metrics) + + yield _serve + for server, middleware in started: + server.stop() + await middleware.reset() + + +async def test_excluded_path_reaches_view_with_scoped_checks_and_response_pass( + serve: ServerFactory, +) -> None: + srv = await serve() + + ok = await srv.get("/health") + assert ok.code == 200 + assert ok.headers["X-Content-Type-Options"] == "nosniff" + assert srv.metrics == ["/health"] + + blocked = await srv.get("/health", XFF_BLOCKED) + assert blocked.code == 403 + assert srv.metrics == ["/health"] + + +async def test_no_client_address_gets_fail_secure_403_by_default( + serve: ServerFactory, +) -> None: + srv = await serve() + + resp = await srv.get("/noclient") + + assert resp.code == 403 + assert b"Client address could not be determined" in resp.body + assert srv.metrics == [] + + +async def test_no_client_address_on_excluded_path_is_scoped_not_bypassed( + serve: ServerFactory, +) -> None: + srv = await serve(exclude_paths=["/noclient-health"]) + + resp = await srv.get("/noclient-health") + + assert resp.code == 200 + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert srv.metrics == ["/noclient-health"] + + +async def test_no_client_address_fail_secure_false_runs_the_pipeline( + serve: ServerFactory, +) -> None: + srv = await serve(fail_secure=False) + + resp = await srv.get("/noclient") + + assert resp.code == 200 + assert srv.metrics == ["/noclient"] + + +async def test_route_bypass_reaches_real_view_and_skips_response_pass( + serve: ServerFactory, +) -> None: + async def modifier(response: GuardResponse) -> GuardResponse: + response.headers["X-Modified"] = "1" + return response + + srv = await serve(custom_response_modifier=modifier) + + resp = await srv.get("/bypassed") + + assert resp.code == 200 + assert b"real view" in resp.body + assert resp.headers["X-Modified"] == "1" + assert srv.metrics == [] + assert "X-Content-Type-Options" not in resp.headers + + +async def test_blocked_request_emits_no_request_metrics(serve: ServerFactory) -> None: + srv = await serve() + + assert (await srv.get("/open", XFF_BLOCKED)).code == 403 + assert srv.metrics == [] + assert (await srv.get("/open")).code == 200 + assert srv.metrics == ["/open"] + + +async def test_custom_response_modifier_reaches_the_client( + serve: ServerFactory, +) -> None: + async def modifier(response: GuardResponse) -> GuardResponse: + response.headers["X-Modified"] = "yes" + return response + + srv = await serve(custom_response_modifier=modifier) + + resp = await srv.get("/open") + + assert resp.code == 200 + assert resp.headers["X-Modified"] == "yes" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + + +async def test_response_modifier_can_replace_the_response( + serve: ServerFactory, +) -> None: + async def modifier(response: GuardResponse) -> GuardResponse: + return TornadoGuardResponse( + status_code=201, + headers={**response.headers, "X-Modified": "yes"}, + body=b"replaced", + ) + + srv = await serve(custom_response_modifier=modifier) + + resp = await srv.get("/open") + + assert resp.code == 201 + assert resp.headers["X-Modified"] == "yes" + assert resp.body == b"replaced" + assert resp.headers["Content-Length"] == "8" + + +async def test_explicit_finish_and_http_errors_get_the_response_pass( + serve: ServerFactory, +) -> None: + srv = await serve() + + explicit = await srv.get("/explicit") + assert explicit.code == 200 + assert explicit.headers["X-Content-Type-Options"] == "nosniff" + + errored = await srv.get("/error") + assert errored.code == 500 + assert errored.headers["X-Content-Type-Options"] == "nosniff" + assert srv.metrics == ["/explicit", "/error"] + + +async def test_preflight_emits_no_request_metrics_and_carries_cors( + serve: ServerFactory, +) -> None: + srv = await serve(enable_cors=True, cors_allow_origins=["https://example.com"]) + + resp = await srv.get( + "/open", + { + "Origin": "https://example.com", + "Access-Control-Request-Method": "GET", + }, + method="OPTIONS", + ) + + assert resp.code == 200 + assert resp.headers["Access-Control-Allow-Origin"] == "https://example.com" + assert srv.metrics == [] + + +async def test_blocked_response_carries_cors_headers(serve: ServerFactory) -> None: + srv = await serve(enable_cors=True, cors_allow_origins=["https://example.com"]) + + resp = await srv.get("/open", {**XFF_BLOCKED, "Origin": "https://example.com"}) + + assert resp.code == 403 + assert resp.headers["Access-Control-Allow-Origin"] == "https://example.com" + + +async def test_global_behavior_return_rule_fires_on_response_body( + serve: ServerFactory, +) -> None: + srv = await serve( + behavior_scan_response_body=True, + global_behavior_rules=[ + { + "rule_type": "return_pattern", + "threshold": 2, + "window": 60, + "pattern": "json:message==Echo response", + "action": "ban", + "ban_duration": 30, + } + ], + ) + + codes = [(await srv.get("/open", XFF_REMOTE)).code for _ in range(5)] + + assert codes[0] == 200 + assert 403 in codes + + +async def test_route_return_rule_fires_on_response_body(serve: ServerFactory) -> None: + srv = await serve(behavior_scan_response_body=True) + + codes = [(await srv.get("/route-rule", XFF_REMOTE)).code for _ in range(5)] + + assert codes[0] == 200 + assert 403 in codes + + +async def test_redis_unavailable_at_init_returns_503(serve: ServerFactory) -> None: + config = SecurityConfig( + enable_redis=True, + redis_url="redis://127.0.0.1:1", + enable_penetration_detection=False, + ) + middleware = SecurityMiddleware(config=config) + middleware.set_decorator_handler(security) + app = tornado.web.Application( + [(r"/open", OpenHandler)], security_middleware=middleware + ) + sockets = tornado.netutil.bind_sockets(0, "127.0.0.1") + server = tornado.httpserver.HTTPServer(app) + server.add_sockets(sockets) + try: + resp = await Server(sockets[0].getsockname()[1], middleware).get("/open") + finally: + server.stop() + + assert resp.code == 503 + assert resp.headers["Retry-After"] == "5" + + +async def test_unawaited_finish_is_idempotent_and_gets_the_response_pass( + serve: ServerFactory, +) -> None: + srv = await serve() + + resp = await srv.get("/sync-finish") + + assert resp.code == 200 + assert b"sync finish" in resp.body + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert srv.metrics == ["/sync-finish"] + + +async def test_route_bypass_of_a_sync_view_that_finishes_with_a_chunk( + serve: ServerFactory, +) -> None: + srv = await serve() + + resp = await srv.get("/sync-bypassed") + + assert resp.code == 200 + assert resp.body == b"sync bypassed" + assert srv.metrics == [] + + +async def test_streamed_response_is_observed_but_headers_are_already_sent( + serve: ServerFactory, +) -> None: + srv = await serve() + + resp = await srv.get("/streaming") + + assert resp.code == 200 + assert resp.body == b"ab" + assert "X-Content-Type-Options" not in resp.headers + assert srv.metrics == ["/streaming"] + + +async def test_response_modifier_can_remove_a_header(serve: ServerFactory) -> None: + async def modifier(response: GuardResponse) -> GuardResponse: + del response.headers["Server"] + return response + + srv = await serve(custom_response_modifier=modifier) + + resp = await srv.get("/open") + + assert resp.code == 200 + assert "Server" not in resp.headers + + +async def test_response_pass_failure_still_finishes_the_response( + serve: ServerFactory, +) -> None: + srv = await serve() + + async def boom(_handler: Any) -> None: + raise RuntimeError("boom") + + srv.middleware.run_post_processing = boom # type: ignore[method-assign] + + resp = await srv.get("/open") + + assert resp.code == 200 + assert b"Echo response" in resp.body diff --git a/tests/test_handler.py b/tests/test_handler.py index 1772be4..2d10ffd 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,20 +1,17 @@ from __future__ import annotations -import asyncio from collections.abc import AsyncGenerator from typing import cast -from unittest.mock import AsyncMock, patch import pytest import tornado.httpserver import tornado.httputil import tornado.netutil import tornado.web -from guard_core.models import SecurityConfig from tornado.httpclient import AsyncHTTPClient from tornado.httputil import HTTPConnection -from tornadoapi_guard import SecurityHandler, SecurityMiddleware +from tornadoapi_guard import SecurityHandler from tornadoapi_guard.handler import SecurityHandler as HandlerClass @@ -61,34 +58,6 @@ async def test_get_security_middleware_rejects_wrong_type() -> None: assert handler._get_security_middleware() is None -async def test_handler_on_finish_schedules_post_processing() -> None: - config = SecurityConfig( - geo_ip_handler=None, - enable_redis=False, - enable_penetration_detection=False, - ) - middleware = SecurityMiddleware(config=config) - await middleware.initialize() - tracker = AsyncMock() - - app = tornado.web.Application( - [(r"/", SimpleHandler)], security_middleware=middleware - ) - request = tornado.httputil.HTTPServerRequest( - method="GET", - uri="/", - headers=tornado.httputil.HTTPHeaders(), - connection=cast(HTTPConnection, _FakeConnection()), - ) - handler = HandlerClass(app, request) - - with patch.object(middleware, "run_post_processing", new=tracker): - handler.on_finish() - await asyncio.sleep(0) - tracker.assert_awaited_once_with(handler) - await middleware.reset() - - class _FakeConnection: def __init__(self) -> None: self.context = tornado.httputil.RequestStartLine("GET", "/", "HTTP/1.1") diff --git a/tests/test_middleware_unit.py b/tests/test_middleware_unit.py index 9007d46..c4e291a 100644 --- a/tests/test_middleware_unit.py +++ b/tests/test_middleware_unit.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -import tornado.httputil from guard_core.decorators.base import RouteConfig from guard_core.handlers.cloud_handler import cloud_handler from guard_core.handlers.security_headers_handler import security_headers_manager @@ -295,64 +294,13 @@ async def test_populate_guard_state_endpoint_missing( assert not hasattr(guard_request.state, "guard_route_id") -async def test_check_bypass_no_client_host( - simple_middleware: SecurityMiddleware, -) -> None: - handler = _make_handler(remote_ip="") - guard_request = TornadoGuardRequest(handler) - result = await simple_middleware._check_bypass(guard_request, None) - assert result is True - - -async def test_check_bypass_path_excluded() -> None: - config = SecurityConfig( - geo_ip_handler=None, - enable_redis=False, - enable_penetration_detection=False, - exclude_paths=["/health"], - ) - middleware = SecurityMiddleware(config=config) - handler = _make_handler(path="/health") - guard_request = TornadoGuardRequest(handler) - result = await middleware._check_bypass(guard_request, None) - assert result is True - await middleware.reset() - - -async def test_check_bypass_route_all( - simple_middleware: SecurityMiddleware, -) -> None: - handler = _make_handler() - guard_request = TornadoGuardRequest(handler) - route_config = RouteConfig() - route_config.bypassed_checks = {"all"} - result = await simple_middleware._check_bypass(guard_request, route_config) - assert result is True - - -async def test_check_bypass_route_all_passive_mode() -> None: - config = SecurityConfig( - geo_ip_handler=None, - enable_redis=False, - enable_penetration_detection=False, - passive_mode=True, - ) - middleware = SecurityMiddleware(config=config) - handler = _make_handler() - guard_request = TornadoGuardRequest(handler) - route_config = RouteConfig() - route_config.bypassed_checks = {"all"} - result = await middleware._check_bypass(guard_request, route_config) - assert result is False - await middleware.reset() - - -async def test_run_pre_processing_bypass_returns_none( +async def test_run_pre_processing_no_client_host_is_fail_secure_403( simple_middleware: SecurityMiddleware, ) -> None: handler = _make_handler(remote_ip="") result = await simple_middleware.run_pre_processing(handler) - assert result is None + assert result is not None + assert result.status_code == 403 async def test_run_pre_processing_lazy_builds_pipeline() -> None: @@ -412,42 +360,6 @@ async def test_run_pre_processing_triggers_usage_rules( mock_rules.assert_awaited() -async def test_apply_pre_flight_headers_disabled() -> None: - config = SecurityConfig( - geo_ip_handler=None, - enable_redis=False, - enable_penetration_detection=False, - security_headers=None, - ) - middleware = SecurityMiddleware(config=config) - fake = _FakeHandler( - request=SimpleNamespace( - method="GET", - path="/test", - protocol="http", - remote_ip="10.0.0.1", - headers=tornado.httputil.HTTPHeaders(), - body=b"", - query_arguments={}, - full_url=lambda: "http://localhost/test", - ), - application=SimpleNamespace(settings={}), - ) - handler = cast(RequestHandler, fake) - await middleware.apply_pre_flight_headers(handler) - assert fake._headers_store == {} - await middleware.reset() - - -async def test_apply_pre_flight_headers_with_cors_origin( - simple_middleware: SecurityMiddleware, -) -> None: - simple_middleware.config.enable_cors = True - simple_middleware.config.cors_allow_origins = ["https://example.com"] - handler = _make_handler(headers={"Origin": "https://example.com"}) - await simple_middleware.apply_pre_flight_headers(handler) - - async def test_run_post_processing_without_request( simple_middleware: SecurityMiddleware, ) -> None: diff --git a/tornadoapi_guard/.agents/skills/tornadoapi-guard/SKILL.md b/tornadoapi_guard/.agents/skills/tornadoapi-guard/SKILL.md index 4a1e9e8..533a1d1 100644 --- a/tornadoapi_guard/.agents/skills/tornadoapi-guard/SKILL.md +++ b/tornadoapi_guard/.agents/skills/tornadoapi-guard/SKILL.md @@ -1,6 +1,6 @@ --- name: tornadoapi-guard -description: TornadoAPI Guard, the Tornado security adapter over guard-core. Use when securing Tornado apps with IP filtering, rate limiting, penetration detection, security headers, cloud-provider blocking, route-level security decorators, or behavioral rules; when wiring SecurityHandler (RequestHandler.prepare/on_finish) or SecurityMiddleware into a tornado.web.Application via the security_middleware setting; when adapting HTTPServerRequest/RequestHandler types to guard-core's GuardRequest/GuardResponse protocols; or when debugging the silent no-guard path when the middleware setting is missing, decorator-route visibility via set_decorator_handler, Redis connectivity, or bytes-valued request.arguments. Also use when porting fastapi-guard patterns to Tornado or choosing the right Guard adapter. +description: TornadoAPI Guard, the Tornado security adapter over guard-core. Use when securing Tornado apps with IP filtering, rate limiting, penetration detection, security headers, cloud-provider blocking, route-level security decorators, or behavioral rules; when wiring SecurityHandler (RequestHandler.prepare/finish) or SecurityMiddleware into a tornado.web.Application via the security_middleware setting; when adapting HTTPServerRequest/RequestHandler types to guard-core's GuardRequest/GuardResponse protocols; or when debugging the silent no-guard path when the middleware setting is missing, decorator-route visibility via set_decorator_handler, Redis connectivity, or bytes-valued request.arguments. Also use when porting fastapi-guard patterns to Tornado or choosing the right Guard adapter. --- # TornadoAPI Guard @@ -16,7 +16,7 @@ TornadoAPI Guard is a thin adapter: all security logic (models, handlers, decora * `SecurityMiddleware(config=...)` is keyword-only; call `await security_middleware.initialize()` before serving. * All behavior is guard-core's `SecurityConfig`; do not mutate handlers directly. * Route rules: `SecurityDecorator` writes per-route `RouteConfig`; wire it with `security_middleware.set_decorator_handler(guard_decorator)` so the pipeline is derived from registered routes. -* Tornado shapes to remember: `prepare()` / `on_finish()` hooks, `request.remote_ip`, case-insensitive `HTTPHeaders`, bytes-valued `request.arguments`, no built-in response object (`handler.write()` / `handler.finish()`). +* Tornado shapes to remember: `prepare()` / `finish()` hooks, `request.remote_ip`, case-insensitive `HTTPHeaders`, bytes-valued `request.arguments`, no built-in response object (`handler.write()` / `handler.finish()`). ## Installation @@ -59,7 +59,7 @@ app = tornado.web.Application( ) ``` -`SecurityHandler.prepare()` runs pre-flight headers, pre-processing (bypass handling, route resolution, the 17-check `SecurityCheckPipeline`), and CORS preflight handling; a blocking verdict is written through `apply_guard_response`. `on_finish()` schedules post-processing (metrics, logging) as a background task. `security_middleware.agent_stats` exposes the agent's live buffer state for health-check endpoints. +`SecurityHandler.prepare()` runs pre-processing (CORS preflight, engine passthrough and bypass handlers, route resolution, the 17-check `SecurityCheckPipeline`); an early response is written through `apply_guard_response` and skips the response pass. `finish()` runs the engine response pass (return rules, metrics, security headers, CORS, modifier) before the flush. `security_middleware.agent_stats` exposes the agent's live buffer state for health-check endpoints. ## Route-Level Security Decorators diff --git a/tornadoapi_guard/adapters.py b/tornadoapi_guard/adapters.py index b7a4180..2c95cf7 100644 --- a/tornadoapi_guard/adapters.py +++ b/tornadoapi_guard/adapters.py @@ -107,6 +107,11 @@ def headers(self) -> MutableMapping[str, str]: def body(self) -> bytes | None: return self._body + async def read_body_prefix(self, max_bytes: int) -> bytes: + if max_bytes <= 0: + return b"" + return (self._body or b"")[:max_bytes] + @classmethod def from_handler(cls, handler: RequestHandler) -> TornadoGuardResponse: raw_headers = getattr(handler, "_headers", None) @@ -159,6 +164,28 @@ def apply_guard_response( handler.finish() +def _normalize_header_name(name: str) -> str: + return "-".join(part.capitalize() for part in name.split("-")) + + +def sync_guard_response( + handler: RequestHandler, + before_headers: Mapping[str, str], + before_body: bytes | None, + response: GuardResponse, +) -> None: + if response.status_code != handler.get_status(): + handler.set_status(response.status_code) + after = {_normalize_header_name(k): v for k, v in response.headers.items()} + for name in before_headers.keys() - after.keys(): + handler.clear_header(name) + for name, value in after.items(): + if before_headers.get(name) != value: + handler.set_header(name, value) + if response.body != before_body: + cast(Any, handler)._write_buffer = [response.body or b""] + + def unwrap_response(guard_response: GuardResponse) -> TornadoGuardResponse: if isinstance(guard_response, TornadoGuardResponse): return guard_response diff --git a/tornadoapi_guard/handler.py b/tornadoapi_guard/handler.py index fd38848..6dd8f3f 100644 --- a/tornadoapi_guard/handler.py +++ b/tornadoapi_guard/handler.py @@ -1,14 +1,14 @@ from __future__ import annotations import asyncio +from typing import Any -from guard_core.sync.handlers.cors_handler import CorsHandler, is_preflight from tornado.web import RequestHandler -from tornadoapi_guard.adapters import apply_guard_response +from tornadoapi_guard.adapters import apply_guard_response, get_guard_state from tornadoapi_guard.middleware import SecurityMiddleware -_background_tasks: set[asyncio.Task[None]] = set() +_background_tasks: set[asyncio.Future[None]] = set() class SecurityHandler(RequestHandler): @@ -17,64 +17,42 @@ async def prepare(self) -> None: if middleware is None: return - await middleware.apply_pre_flight_headers(self) - - blocking = await middleware.run_pre_processing(self) - cors_handler = _build_cors_handler(middleware) - - if blocking is not None: - _apply_cors_response_headers(self, cors_handler) - apply_guard_response(self, blocking) - return - - if _handle_preflight(self, cors_handler): - return - - _apply_cors_response_headers(self, cors_handler) - - def on_finish(self) -> None: - middleware = self._get_security_middleware() - if middleware is None: - return - task = asyncio.create_task(middleware.run_post_processing(self)) + early = await middleware.run_pre_processing(self) + if early is not None: + apply_guard_response(self, early) + + def finish(self, chunk: str | bytes | dict[str, Any] | None = None) -> Any: + state = get_guard_state(self) + pending = getattr(state, "guard_finish", None) + if pending is not None: + return pending + if getattr(state, "guard_capturing", False): + if chunk is not None: + self.write(chunk) + done: asyncio.Future[None] = asyncio.get_running_loop().create_future() + done.set_result(None) + return done + if not getattr(state, "guard_response_pass", False): + return super().finish(chunk) + task = asyncio.ensure_future(self._guard_finish(chunk)) + state.guard_finish = task _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) + return task + + async def _guard_finish(self, chunk: str | bytes | dict[str, Any] | None) -> None: + middleware = self._get_security_middleware() + assert middleware is not None + try: + if chunk is not None: + self.write(chunk) + await middleware.run_post_processing(self) + except Exception: + middleware.logger.exception("Response processing failed") + await super().finish() def _get_security_middleware(self) -> SecurityMiddleware | None: value = self.application.settings.get("security_middleware") if isinstance(value, SecurityMiddleware): return value return None - - -def _build_cors_handler(middleware: SecurityMiddleware) -> CorsHandler | None: - if not middleware.config.enable_cors: - return None - return CorsHandler(middleware.config) - - -def _apply_cors_response_headers( - handler: RequestHandler, cors_handler: CorsHandler | None -) -> None: - if cors_handler is None: - return - for k, v in cors_handler.build_response_headers( - dict(handler.request.headers) - ).items(): - handler.set_header(k, v) - - -def _handle_preflight( - handler: RequestHandler, cors_handler: CorsHandler | None -) -> bool: - if cors_handler is None: - return False - if not is_preflight(handler.request.method or "", dict(handler.request.headers)): - return False - preflight = cors_handler.build_preflight_response(dict(handler.request.headers)) - handler.set_status(preflight.status_code) - for k, v in preflight.headers.items(): - handler.set_header(k, v) - handler.write(preflight.body) - handler.finish() - return True diff --git a/tornadoapi_guard/middleware.py b/tornadoapi_guard/middleware.py index 3ca023e..493b216 100644 --- a/tornadoapi_guard/middleware.py +++ b/tornadoapi_guard/middleware.py @@ -1,10 +1,13 @@ from __future__ import annotations import asyncio +import inspect import time +from collections.abc import Awaitable, Callable, Mapping from typing import TYPE_CHECKING, Any, cast from guard_core.core.behavioral import BehavioralContext, BehavioralProcessor +from guard_core.core.bypass import BypassContext, BypassHandler from guard_core.core.checks.pipeline import SecurityCheckPipeline from guard_core.core.events import MetricsCollector, SecurityEventBus from guard_core.core.initialization import HandlerInitializer @@ -12,7 +15,9 @@ from guard_core.core.routing import RouteConfigResolver, RoutingContext from guard_core.core.validation import RequestValidator, ValidationContext from guard_core.decorators.base import BaseSecurityDecorator, RouteConfig +from guard_core.exceptions import GuardRedisError from guard_core.handlers.cloud_handler import cloud_handler +from guard_core.handlers.cors_handler import CorsHandler, is_preflight from guard_core.handlers.ratelimit_handler import RateLimitManager from guard_core.handlers.security_headers_handler import security_headers_manager from guard_core.models import SecurityConfig @@ -24,6 +29,8 @@ TornadoGuardResponse, TornadoResponseFactory, get_guard_state, + sync_guard_response, + unwrap_response, ) if TYPE_CHECKING: @@ -80,6 +87,9 @@ def __init__(self, *, config: SecurityConfig) -> None: self.security_pipeline: SecurityCheckPipeline | None = None self._guard_response_factory = TornadoResponseFactory() + self._cors_handler: CorsHandler | None = ( + CorsHandler(config) if config.enable_cors else None + ) self.handler_initializer = HandlerInitializer( config=self.config, @@ -143,6 +153,16 @@ def _build_event_bus_and_contexts(self) -> None: ) self.validator = RequestValidator(validation_context) + bypass_context = BypassContext( + config=self.config, + logger=self.logger, + event_bus=self.event_bus, + route_resolver=self.route_resolver, + response_factory=self.response_factory, + validator=self.validator, + ) + self.bypass_handler = BypassHandler(bypass_context) + behavioral_context = BehavioralContext( config=self.config, logger=self.logger, @@ -296,88 +316,119 @@ def _populate_guard_state( if module and qualname: guard_request.state.guard_endpoint_id = f"{module}.{qualname}" - async def _check_bypass( - self, guard_request: TornadoGuardRequest, route_config: RouteConfig | None - ) -> bool: - if not guard_request.client_host: - return True - - if await self.validator.is_path_excluded(guard_request): - return True - - if route_config and self.route_resolver.should_bypass_check( - "all", route_config - ): - await self.event_bus.send_middleware_event( - event_type="security_bypass", - request=guard_request, - action_taken="all_checks_bypassed", - reason="Route configured to bypass all security checks", - bypassed_checks=list(route_config.bypassed_checks), - endpoint=str(guard_request.url_path), - ) - if not self.config.passive_mode: - return True - - return False - - async def run_pre_processing(self, handler: RequestHandler) -> GuardResponse | None: - await self._ensure_initialized() + def _inject_cors( + self, response: GuardResponse, request_headers: Mapping[str, str] + ) -> None: + if self._cors_handler is None: + return + cors_headers = self._cors_handler.build_response_headers(dict(request_headers)) + for key, value in cors_headers.items(): + response.headers[key] = value + + def _view_caller( + self, handler: RequestHandler + ) -> Callable[[Any], Awaitable[GuardResponse]]: + async def call_view(_guard_request: Any) -> GuardResponse: + state = get_guard_state(handler) + state.guard_capturing = True + try: + view = getattr(handler, (handler.request.method or "GET").lower()) + result = view(*handler.path_args, **handler.path_kwargs) + if inspect.isawaitable(result): + await result + finally: + state.guard_capturing = False + return TornadoGuardResponse.from_handler(handler) + + return call_view + + async def _handle_preflight( + self, guard_request: TornadoGuardRequest + ) -> GuardResponse | None: + if self._cors_handler is None: + return None + headers = dict(guard_request.headers) + if not is_preflight(guard_request.method, headers): + return None + assert self.security_pipeline is not None + blocking = await self.security_pipeline.execute(guard_request) + if blocking is not None: + self._inject_cors(blocking, headers) + return blocking + preflight = self._cors_handler.build_preflight_response(headers) + return TornadoGuardResponse( + status_code=preflight.status_code, + headers=dict(preflight.headers), + body=preflight.body.encode("utf-8"), + ) - guard_request = TornadoGuardRequest(handler) - self._populate_guard_state(guard_request, handler) + async def _redis_unavailable_response(self) -> GuardResponse: + response = await self.create_error_response( + 503, "Service temporarily unavailable" + ) + response.headers["Retry-After"] = "5" + return response + async def _guard_phase( + self, handler: RequestHandler, guard_request: TornadoGuardRequest + ) -> GuardResponse | None: assert self.security_pipeline is not None + call_view = self._view_caller(handler) + + passthrough = await self.bypass_handler.handle_passthrough( + guard_request, call_view + ) + if passthrough is not None: + return passthrough client_ip = await extract_client_ip( guard_request, self.config, self.agent_handler ) route_config = self.route_resolver.get_route_config(guard_request) - - guard_request.state.guard_request = guard_request guard_request.state.route_config = route_config guard_request.state.client_ip = client_ip - guard_request.state.request_start_time = time.time() - if await self._check_bypass(guard_request, route_config): - return None + bypass = await self.bypass_handler.handle_security_bypass( + guard_request, call_view, route_config + ) + if bypass is not None: + return bypass blocking = await self.security_pipeline.execute(guard_request) if blocking is not None: - blocking = await self.response_factory.apply_security_headers( - blocking, str(guard_request.url_path) - ) - origin = guard_request.headers.get("origin") - if origin: - blocking = await self.response_factory.apply_cors_headers( - blocking, origin - ) - return await self.response_factory.apply_modifier(blocking) + return blocking if route_config and route_config.behavior_rules and client_ip: await self.behavioral_processor.process_usage_rules( guard_request, client_ip, route_config ) - return None - async def apply_pre_flight_headers(self, handler: RequestHandler) -> None: - if not ( - self.config.security_headers - and self.config.security_headers.get("enabled", True) - ): - return + async def run_pre_processing(self, handler: RequestHandler) -> GuardResponse | None: + guard_request = TornadoGuardRequest(handler) + try: + await self._ensure_initialized() + except GuardRedisError as e: + self.logger.error("Redis unavailable during initialization: %s", e) + unavailable = await self._redis_unavailable_response() + self._inject_cors(unavailable, guard_request.headers) + return unavailable + + self._populate_guard_state(guard_request, handler) + guard_request.state.guard_request = guard_request + guard_request.state.request_start_time = time.time() - path = handler.request.path - headers = await security_headers_manager.get_headers(path) - for name, value in headers.items(): - handler.set_header(name, value) + preflight = await self._handle_preflight(guard_request) + if preflight is not None: + return preflight - origin = handler.request.headers.get("Origin") - if origin: - cors_headers = await security_headers_manager.get_cors_headers(origin) - for name, value in cors_headers.items(): - handler.set_header(name, value) + early = await self._guard_phase(handler, guard_request) + if early is not None: + self._inject_cors(early, guard_request.headers) + return early + + guard_request.state.guard_response_pass = True + return None async def run_post_processing(self, handler: RequestHandler) -> None: state = get_guard_state(handler) @@ -392,14 +443,23 @@ async def run_post_processing(self, handler: RequestHandler) -> None: response_time = time.time() - start_time guard_response = TornadoGuardResponse.from_handler(handler) + before_headers = dict(guard_response.headers) + before_body = guard_response.body - await self.response_factory.process_response( + result = await self.response_factory.process_response( guard_request, guard_response, response_time, route_config, process_behavioral_rules=self.behavioral_processor.process_return_rules, + process_global_behavioral_rules=( + self.behavioral_processor.process_global_return_rules + ), ) + final = unwrap_response(result) + self._inject_cors(final, guard_request.headers) + if not cast(Any, handler)._headers_written: + sync_guard_response(handler, before_headers, before_body, final) async def refresh_cloud_ip_ranges(self) -> None: if not self.config.block_cloud_providers: