Protocols¶
Protocols are the most important API surface for adapter developers. Guard-core uses Python Protocol classes (PEP 544) to define the contracts that adapters must satisfy. All protocols are @runtime_checkable.
GuardRequest¶
The request protocol defines how guard-core reads incoming request data. Adapters must wrap their framework's request object to satisfy this interface.
@runtime_checkable
class GuardRequest(Protocol):
@property
def url_path(self) -> str: ...
@property
def url_scheme(self) -> str: ...
@property
def url_full(self) -> str: ...
def url_replace_scheme(self, scheme: str) -> str: ...
@property
def method(self) -> str: ...
@property
def client_host(self) -> str | None: ...
@property
def headers(self) -> Mapping[str, str]: ...
@property
def query_params(self) -> Mapping[str, str]: ...
async def body(self) -> bytes: ...
@property
def state(self) -> Any: ...
@property
def scope(self) -> dict[str, Any]: ...
Member Details¶
| Member | Return Type | Description |
|---|---|---|
url_path |
str |
The path component of the URL (e.g., "/api/users"). |
url_scheme |
str |
The URL scheme ("http" or "https"). |
url_full |
str |
The full URL string including scheme, host, path, and query. |
url_replace_scheme |
str |
Returns the full URL with the scheme replaced. Used for HTTPS redirects. |
method |
str |
The HTTP method ("GET", "POST", etc.). |
client_host |
str \| None |
The connecting client's IP address. None if unavailable. |
headers |
Mapping[str, str] |
Request headers as a read-only mapping. Case handling depends on adapter. |
query_params |
Mapping[str, str] |
URL query parameters as a read-only mapping. |
body() |
bytes (async) |
The raw request body. May be called multiple times by detection checks. |
state |
Any |
A mutable state object for passing data between checks. Must support attribute assignment. |
scope |
dict[str, Any] |
ASGI-style scope dict. Used for route resolution (scope["route"]). |
Implementation Notes¶
state: Guard-core sets these attributes on state during pipeline execution:
state.route_config--RouteConfig | Nonestate.client_ip--strstate.is_whitelisted--bool
The adapter's state object must support dynamic attribute assignment (e.g., a simple namespace or the framework's built-in state).
scope: Must include a "route" key with an object that has an endpoint attribute for decorator resolution. If the framework does not have route objects in scope, decorator-based features will not activate.
body(): The detection engine calls body() to scan request bodies for threats. Adapters should ensure the body is buffered and can be read multiple times (not consumed on first read).
headers: Must be iterable via .items() for header scanning. Guard-core reads headers case-insensitively in many places (e.g., headers.get("User-Agent")), but the mapping itself does not need to be case-insensitive.
BoundedBodyReader¶
Optional capability protocol. Import it from guard_core.protocols (or guard_core.sync.protocols for the blocking mirror, SyncBoundedBodyReader). An adapter implements it alongside GuardRequest to let detection inspect a size-capped prefix of a body whose Content-Length is missing or unusable (for example Transfer-Encoding: chunked), instead of that body being skipped entirely.
@runtime_checkable
class BoundedBodyReader(Protocol):
async def read_body_prefix(self, max_bytes: int) -> bytes: ...
| Member | Return Type | Description |
|---|---|---|
read_body_prefix() |
bytes (async) |
Read at most max_bytes of the body, from its start. |
An adapter that only implements GuardRequest is still fully valid; guard-core treats the absence of BoundedBodyReader as "cannot bound the read" and skips the body rather than reading it in full.
The call is timeout-bounded in the async tree only
In guard_core (async), guard-core waits at most SecurityConfig.body_read_timeout seconds (default 3.0) for read_body_prefix to return, whether the adapter is implementing BoundedBodyReader or GuardRequest.body. A stalled adapter (a stuck SSE producer, a long-poll that never yields, a buggy implementation) degrades to the same fail-closed "body unavailable" outcome a raising reader already produces, instead of hanging the request indefinitely. The stalled call itself cannot be cancelled -- asyncio cannot safely kill a call mid-flight -- so it keeps running in the background after guard-core gives up waiting on it; the timeout bounds guard-core's own wait, not the adapter's work. In guard_core.sync, read_body_prefix/body is called directly with no bound at all: body_read_timeout is ignored, and a stalled sync adapter blocks the request for as long as it takes, exactly like any other slow call in a WSGI application. Bound it at the WSGI server instead (gunicorn --timeout, uWSGI harakiri); guard-core does not and cannot safely cancel a blocking call from the outside without spawning unbounded background threads, which an earlier release of this protocol did and removed after it proved to leak threads under a sustained stall and silently drop detection under ordinary concurrent load once its fixed thread pool was exhausted.
Detection only sees the prefix
Whichever mechanism produces the bytes to scan (Content-Length under the cap, or read_body_prefix), detection only ever inspects the leading detection_max_body_inspect_bytes bytes of the body. A payload placed after that many bytes of filler, or a signature split across the boundary, is not detected. This is the deliberate memory/detection tradeoff of bounded-memory scanning, not a bug, and it is not equivalent to full-body scanning.
Implementations must enforce the bound themselves
guard-core defensively slices the returned bytes to max_bytes, but that slice only trims what read_body_prefix already returned -- it cannot stop an implementation from reading or buffering more than max_bytes internally first. An implementation that ignores max_bytes and reads the full body before returning defeats the memory bound this protocol exists to provide (see GHSA-xv6g-49vj-7w9c). Implementations must not buffer more than max_bytes while producing the prefix.
GuardResponse¶
The response protocol defines how guard-core reads and modifies outgoing responses.
@runtime_checkable
class GuardResponse(Protocol):
@property
def status_code(self) -> int: ...
@property
def headers(self) -> MutableMapping[str, str]: ...
@property
def body(self) -> bytes | None: ...
Member Details¶
| Member | Return Type | Description |
|---|---|---|
status_code |
int |
HTTP status code. Used directly by status: return-pattern rules. |
headers |
MutableMapping[str, str] |
Response headers. Must be mutable for security header injection. |
body |
bytes \| None |
Response body bytes, when already fully materialized. Not read for behavioral return_pattern matching -- see BoundedResponseBodyReader below for that. |
BoundedResponseBodyReader¶
Optional capability protocol. Import it from guard_core.protocols (or guard_core.sync.protocols for the blocking mirror, SyncBoundedResponseBodyReader). An adapter implements it alongside GuardResponse to let return_pattern behavior rules (json:, regex:, bare-substring) inspect a size-capped prefix of the response body, gated by SecurityConfig.behavior_scan_response_body (default False).
@runtime_checkable
class BoundedResponseBodyReader(Protocol):
async def read_body_prefix(self, max_bytes: int) -> bytes: ...
| Member | Return Type | Description |
|---|---|---|
read_body_prefix() |
bytes (async) |
Read at most max_bytes of the body, from its start, without disrupting delivery of the rest to the client. |
BehaviorTracker._check_response_pattern never reads GuardResponse.body and never probes for this capability with hasattr -- capability detection is an explicit isinstance check against this protocol, which is safe because read_body_prefix is a plain method rather than a property (an isinstance check on a runtime_checkable Protocol never invokes a method member, only a property member). An adapter that only implements GuardResponse is still fully valid; guard-core treats the absence of BoundedResponseBodyReader as "cannot evaluate this rule" rather than as "no match" -- it returns None from _check_response_pattern (distinct from False) and logs a throttled warning, instead of silently reporting no match. This read is not cached across rules: each return_pattern rule checked against a response calls read_body_prefix independently and pays its own bounded (async tree) or unbounded (sync tree) read.
Lockstep upgrade required for every adapter
Before this capability existed, _check_response_pattern read GuardResponse.body directly (guarded by hasattr). For an ordinary, non-streaming response whose .body is a plain, non-raising, already-materialized attribute, that old codepath matched correctly. It does not match under the current release, behavior_scan_response_body=True or not, until the adapter implements BoundedResponseBodyReader.read_body_prefix. guard-core, fastapi-guard, flaskapi-guard, and djapi-guard are separate repositories, each adapter pins guard-core with no version constraint, and upgrading guard-core alone -- without also upgrading the adapter to a release that implements this protocol -- silently drops every body-reading return_pattern rule for that adapter, with no error and no signal beyond the pre-existing throttled could-not-evaluate log line. status: patterns are unaffected, since they never touch the body.
A streaming response must keep streaming
Unlike the request side, guard-core does not own delivery of the response to the client -- the adapter does, after the security pipeline returns. read_body_prefix MUST NOT consume the underlying stream to completion, MUST NOT block waiting for more data than the stream is currently ready to produce (an indefinite stream such as server-sent events or long polling may never reach max_bytes), and the response object MUST still deliver its complete, unbounded body to the client afterward exactly as if read_body_prefix had never been called. The standard implementation shape is a tee: buffer chunks up to max_bytes from the underlying stream, return that prefix, and replace the outgoing body iterator with one that replays the buffered bytes followed by the untouched remainder of the original stream. Buffer-then-forward (reading the whole body before sending any of it to the client) is not an acceptable implementation: it turns every large download into a memory spike and breaks SSE and long polling.
Implementations must enforce the bound themselves
Exactly as with BoundedBodyReader on the request side (see GHSA-xv6g-49vj-7w9c): guard-core defensively slices the returned bytes to max_bytes, but that slice only trims what read_body_prefix already returned -- it cannot stop an implementation from reading or buffering more than max_bytes internally first, and it cannot force a streaming response to keep streaming. Implementations must not buffer more than max_bytes while producing the prefix. On requests the attacker controls the body directly; on responses the attacker controls only which endpoint they hit and the application produces the body, so any large streaming endpoint (a file download, an export, an SSE stream) can force this read on every request through it once behavior_scan_response_body is enabled -- size the cap and pick routes accordingly.
GuardResponseFactory¶
Adapters must provide a factory that creates framework-native response objects.
@runtime_checkable
class GuardResponseFactory(Protocol):
def create_response(self, content: str, status_code: int) -> GuardResponse: ...
def create_redirect_response(self, url: str, status_code: int) -> GuardResponse: ...
| Method | Purpose |
|---|---|
create_response |
Creates a plain text/JSON error response. |
create_redirect_response |
Creates an HTTP redirect (used for HTTPS enforcement). |
GuardMiddlewareProtocol¶
Defines the interface that the adapter's middleware class must expose to the security check pipeline.
@runtime_checkable
class GuardMiddlewareProtocol(Protocol):
config: SecurityConfig
logger: logging.Logger
last_cloud_ip_refresh: int
suspicious_request_counts: dict[str, dict[str, int]]
@property
def event_bus(self) -> Any: ...
@property
def route_resolver(self) -> Any: ...
@property
def response_factory(self) -> Any: ...
@property
def rate_limit_handler(self) -> Any: ...
@property
def agent_handler(self) -> Any: ...
@property
def geo_ip_handler(self) -> Any: ...
@property
def guard_response_factory(self) -> Any: ...
async def create_error_response(
self, status_code: int, default_message: str
) -> GuardResponse: ...
async def refresh_cloud_ip_ranges(self) -> None: ...
Attributes¶
| Attribute | Type | Description |
|---|---|---|
config |
SecurityConfig |
The security configuration. |
logger |
logging.Logger |
Logger instance for the middleware. |
last_cloud_ip_refresh |
int |
Timestamp of the last cloud IP refresh. |
suspicious_request_counts |
dict[str, dict[str, int]] |
Nested counters indexed by IP, then by detection category (xss, sqli, custom, ...). Read the total via sum(values()). |
Properties¶
| Property | Purpose |
|---|---|
event_bus |
SecurityEventBus for emitting security events. |
route_resolver |
RouteConfigResolver for decorator resolution. |
response_factory |
ErrorResponseFactory for creating error responses. |
rate_limit_handler |
RateLimitManager instance. |
agent_handler |
Agent handler or None. |
geo_ip_handler |
GeoIPHandler or None. |
guard_response_factory |
GuardResponseFactory from the adapter. |
GeoIPHandler¶
Protocol for geolocation services. Adapters can provide any implementation (MaxMind, IPInfo, custom).
@runtime_checkable
class GeoIPHandler(Protocol):
@property
def is_initialized(self) -> bool: ...
async def initialize(self) -> None: ...
async def initialize_redis(self, redis_handler: RedisHandlerProtocol) -> None: ...
async def initialize_agent(self, agent_handler: AgentHandlerProtocol) -> None: ...
def get_country(self, ip: str) -> str | None: ...
async def refresh(self) -> None: ...
def close(self) -> None: ...
| Method | Description |
|---|---|
is_initialized |
Whether the handler has been initialized. |
initialize() |
Perform async initialization (e.g., download DB). |
get_country(ip) |
Return ISO country code for the IP, or None. |
refresh() |
Reload the dataset from source (periodic update). Required by the protocol even if a given implementation makes it a no-op. |
close() |
Release the dataset/file handles. |
RedisHandlerProtocol¶
Protocol for Redis operations. Matches the RedisManager interface.
@runtime_checkable
class RedisHandlerProtocol(Protocol):
async def get_key(self, namespace: str, key: str) -> Any: ...
async def set_key(
self, namespace: str, key: str, value: Any, ttl: int | None = None
) -> bool | None: ...
async def delete(self, namespace: str, key: str) -> int | None: ...
async def keys(self, pattern: str) -> list[str] | None: ...
async def initialize(self) -> None: ...
def get_connection(self) -> AsyncContextManager[Any]: ...
AgentHandlerProtocol¶
Protocol for the Guard Agent telemetry system.
@runtime_checkable
class AgentHandlerProtocol(Protocol):
async def initialize_redis(self, redis_handler: RedisHandlerProtocol) -> None: ...
async def send_event(self, event: Any) -> None: ...
async def send_metric(self, metric: Any) -> None: ...
async def start(self) -> None: ...
async def stop(self) -> None: ...
async def flush_buffer(self) -> None: ...
async def get_dynamic_rules(self) -> Any | None: ...
async def health_check(self) -> bool: ...