Application-layer security middleware for [Rocket](https://rocket.rs) 0.5, powered by the [guard-core-rs](https://github.com/Guard-Core/guard-core-rs) detection engine. Part of the [guard ecosystem](https://github.com/Guard-Core).
Website · Docs · Playground · Dashboard · Discord
Guard Core is the Python engine. Framework adapters are thin wrappers that translate native request/response types into Guard Core's protocols. The telemetry agents ship security events and metrics to the monitoring backend. Parallel engine implementations exist for Go, PHP, TypeScript (on npm), and Rust (on crates.io) - all ports of the same reference semantics, conformance-tested against the shared adversarial corpus.
| Package | Role | PyPI |
|---|---|---|
| guard-core | Framework-agnostic security engine | |
| guard-agent | Telemetry agent | |
| fastapi-guard | FastAPI / Starlette adapter | |
| flaskapi-guard | Flask adapter | |
| djapi-guard | Django adapter | |
| tornadoapi-guard | Tornado adapter |
Go modules published via GitHub releases. Production-ready.
| Package | Role | Release |
|---|---|---|
| guard-core-go | Go engine | |
| nethttp-guard | net/http adapter | |
| gin-guard | Gin adapter | |
| echo-guard | Echo (v4) adapter | |
| fiber-guard | Fiber (v3) adapter | |
| guard-agent-go | Telemetry agent |
Published on Packagist under the rennf93 vendor. Production-ready.
| Package | Role | Packagist |
|---|---|---|
| guard-core-php | PHP engine | |
| laravel-guard | Laravel adapter | |
| symfony-guard | Symfony adapter | |
| psr15-guard | PSR-15 adapter | |
| slim-guard | Slim 4 adapter | |
| guard-agent-php | Telemetry agent |
Published under the @guardcore npm scope; source in the guard-core-ts monorepo. Production-ready.
| Package | Role | npm |
|---|---|---|
| @guardcore/core | Core engine | |
| @guardcore/express | Express adapter | |
| @guardcore/nestjs | NestJS adapter | |
| @guardcore/fastify | Fastify adapter | |
| @guardcore/hono | Hono (edge) adapter | |
| guardagent | Telemetry agent |
Published on crates.io. Production-ready.
| Package | Role | crates.io |
|---|---|---|
| guard-core-engine | Core engine crate | |
| guard-core-rs | Facade crate (consumer entry point) | |
| actix-guard-rs | Actix Web adapter | |
| axum-guard-rs | Axum adapter | |
| tower-guard-rs | Tower adapter | |
| rocket-guard-rs | Rocket adapter | |
| guard-agent-rs | Telemetry agent |
| Package | Role | PyPI |
|---|---|---|
| guard-core-mcp | MCP server: config validation, docs search, detection sandbox |
📚 Documentation - full technical documentation for this package.
🛡️ Guard Core - the engine's reference documentation.
🤖 Monitoring Agent Integration - monitor your Guard instance with a monitoring agent.
The guard ecosystem provides application-layer API security middleware across multiple languages and frameworks:
- Python: fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard
- TypeScript: guard-core-ts with adapters for Express, Fastify, Hono, NestJS
- Rust: guard-core-rs with adapters for tower, axum, actix-web, rocket (this repo)
Per the ecosystem boundary rules, this crate holds framework glue only: every detection decision comes from the engine.
Rocket has no middleware chain that can abort a request: a fairing's on_request cannot short-circuit, and Rocket's own source calls request guards "the correct mechanism" for refusal. The adapter splits the work accordingly:
- Attach the fairing.
GuardFairingscans the path, query, and header views of every request inon_requestand stashes the verdict in request-local state; it also registers the400/403/413/429/500catchers that render refusals (skipping statuses the application already registered, since Rocket treats same-code catchers at the same base as a fatal collision). - Add a guard argument to each protected route.
BlockGuardfor routes without a body,GuardBodyfor routes with one. This is the part Rocket cannot automate: protection is per-route by design.
use rocket::{get, post, routes};
use rocket_guard_rs::{BlockGuard, GuardBody, GuardFairing, default_config};
#[get("/health")]
fn health(_guard: BlockGuard) -> &'static str {
"ok"
}
#[post("/submit", data = "<body>")]
fn submit(body: GuardBody) -> Vec<u8> {
body.into_inner()
}
#[rocket::launch]
fn rocket() -> _ {
rocket::build()
.attach(GuardFairing::new(default_config()))
.mount("/", routes![health, submit])
}Routes without either guard argument are scanned but not blocked. The fairing additionally rewrites a 404 to the guarded refusal shape when the verdict is a block, so a threat to a path that matches no route does not leak a 404; a 404 is proof that no handler ran, which is why that rewrite is safe.
The full crate documentation is in src/lib.rs (build it with cargo doc --open).
One engine call per request view, mirroring the mapping used by the sibling adapters:
| Request part | Engine context | Notes |
|---|---|---|
| Path | url_path |
Skipped for / |
| Query string | query_param |
Skipped when empty |
| Header values | header |
Skips sec-* and the negotiation/routing headers (Host, User-Agent, Accept, Accept-Encoding, Connection, Origin, Referer) |
| Body | request_body |
Buffered by GuardBody, capped (see below) |
The HTTP method is not fed to the engine: the engine's detect(content, context, config) takes content plus a context, and the reference adapters do not scan the method either.
Every stateful decision and emission runs through the engine facade's rate-limit stage, configured through GuardFairing builders:
| Surface | Builder / idiom |
|---|---|
| Rate-limit tiers | .with_route_tiers(resolver) (path -> Option<RouteRateLimits>), .with_geo_handler(handler) (geo tiers); per-request override via set_route_rate_limits(&request, tiers) |
| Detection exclusions | .with_detection_exclusions(config) (global excluded_detection_headers/params/body_fields, enabled_detection_categories, detection_scan_body), .with_route_detection_exclusions(resolver) (per route), or the set_route_detection_exclusions(&request, exclusions) request-local setter |
| Events + log settings | .with_event_bus(bus) (SecurityEventBus hook registration), .with_observability(config) (log_suspicious_level, muted_check_logs, the log_sensitive_headers/params/body_fields redaction sets) |
on_block + custom errors |
.with_on_block(hook), .with_custom_error_responses(map) (status-to-body overrides on every block answer, including the 400 detection block, honored by the catchers and the 404 rewrite) |
| Distributed mode | .with_distributed_store(window_store, prefix, fail_open) + .with_distributed_ban_store(ban_store) (fail-closed backend errors answer 503 Redis rate limiting unavailable) |
| Passive mode | .with_passive_mode(true) (windows and counters still record, log lines and events still fire, no block renders, auto-ban feeds suppressed) |
Two-phase note (framework shape, reference-matching order): Rocket's on_request never sees the request body, so the fairing runs the full stage pass (bans, rate-limit tiers, the metadata finding) in on_request - recording the request's single rate-window hit there - and the body finding feeds later through the stage's split feed_finding seam, exactly how the reference pipeline orders rate limiting before body processing.
| Situation | Status | Body |
|---|---|---|
No peer address and no resolvable client under fail_secure |
403 Forbidden |
Client address could not be determined |
| The IP gate denies the client IP | 403 Forbidden |
Forbidden |
| A live ban on the client IP | 403 Forbidden |
IP address banned |
| Rate limit crossed | 429 Too Many Requests (+ Retry-After: <window>) |
Too many requests |
| Engine flags a view | 400 Bad Request |
Suspicious activity detected |
| Engine flags a view and a crossed auto-ban threshold bans on the spot | 403 Forbidden |
IP has been banned |
| Body exceeds the cap | 413 Payload Too Large |
Payload too large |
| Body read error or engine panic | 500 Internal Server Error |
Security check failed |
The bodies follow the ecosystem's plain-text error convention (the bare message, text/plain; charset=utf-8, same as the Python family), but the adapter is deliberately fail-secure: unlike the TypeScript adapters, whose check pipeline logs and skips on error, any failure to complete the security check answers 500, never an uninspected passthrough. A guard with no stashed verdict (fairing not attached) also refuses with 500.
Engine panics are caught with catch_unwind, so a detected panic still produces a response instead of unwinding out of the request. panic = "abort" in the release profile disables that recovery.
Two opt-in builder methods install the engine's stateful stage, mirroring the reference pipeline's order (ban check first, then the limiter, both in on_request before the metadata scan and the guards):
use rocket_guard_rs::{GuardFairing, IpBanConfig, IpBanManager, RateLimitConfig, RateLimiter, ThreatBanEntry};
let limiter = RateLimiter::new(RateLimitConfig {
enable_rate_limiting: true,
rate_limit: 30,
rate_limit_window: 10,
..RateLimitConfig::default()
})
.expect("valid config");
let manager = IpBanManager::new();
let bans = IpBanConfig::new(
true,
10,
3600,
[("sqli", ThreatBanEntry { threshold: 3, duration: 1800 })],
)
.expect("valid config");
let fairing = rocket_guard_rs::GuardFairing::with_defaults()
.with_rate_limiting(limiter)
.with_ip_banning(manager, bans);- A rate-limit crossing answers
429 Too Many RequestswithRetry-After: <window seconds>. With the limiter'senable_rate_limit_auto_banon, every crossing counts onerate_limitviolation toward the auto-ban engine; the response stays429and the ban bites on the next request (403 IP address banned). - A live ban on the client IP answers
403 Forbidden(IP address banned) before the limiter, so banned traffic never consumes rate budget. - Every detected threat counts its categories per client IP; a crossed
threat_ban_configentry (or the flatauto_ban_threshold) bans on the spot, answering403 Forbidden(IP has been banned).config.enable_ip_banning = falsecounts violations but never bans. - Both stages honor the
exempt_ipscontract: whitelisted and exempt IPs are never rate limited, never banned, and never counted; unattributed requests (no client IP) skip the stage but are still detection-screened. - The limiter and the ban store pair are shared with the managed engine state, and the engine handles are cheaply clonable, so out-of-band handles (admin unban endpoints, stats) work alongside the installed fairing.
The Rocket peer address is the connecting peer. The identity every stage runs under (gate, bans, rate limit, geo, cloud, events, the response pass) is resolved from it with the reference rules: X-Forwarded-For counts only behind a trusted proxy (trusted_proxies, trusted_proxy_depth), and an IPv4-mapped IPv6 peer is canonicalized. Rocket's own Request::client_ip() is never consulted, because it honors the X-Real-IP header from any peer. A request with no peer address and no resolvable client answers 403 Client address could not be determined under fail_secure, before any check, and the on_block hook sees check name client_address_unresolved. With fail_secure = false it continues unattributed. A bare fairing defaults to no proxies and fail_secure = false, so existing hosts that supply no peer keep passing; .with_client_policy(ClientPolicy { .. }) installs a policy and from_security_config installs the config's (fail_secure defaults to true). The WebSocket guard takes the same policy through WebSocketGuardConfig::with_client_policy. The HTTPS stage matches trusted_proxies against the connecting peer and takes the transport's own scheme, never a header's claim.
exclude_paths uses the reference matcher: /static covers /static and /static/app.js and never /staticx, and percent-encoding and dot segments are resolved before matching. An excluded request is scoped, not bypassed. It carries the GuardExclusionScoped marker (read it as an Option<GuardExclusionScoped> request guard) and runs route resolution, the ban check, the global IP lists, country and cloud rules, and the rate limit. Emergency mode, HTTPS enforcement, request logging, headers and auth, validators, the scan (the GuardBody body scan included, its size cap stays as the memory bound), the user-agent filter, custom checks and the return rules are skipped. The response still gets metrics, security headers, CORS and the modifier. The CORS preflight short-circuit runs before the exclusion decision. A route carrier that bypasses all checks skips the pipeline: the security_bypass event fires and only the modifier runs on the response (under passive_mode the request continues through the pipeline). Rocket cannot answer from on_request, so these refusals ride the same verdict, guard and catcher path as every other block.
GuardFairing::from_security_config(&config) builds everything through the facade's Wiring, the one SecurityConfig mapping the Rust adapters share (the field-by-field table is in the guard_core_rs::wiring module docs). Geo country rules, cloud providers (with the per-request range refresh), endpoint_rate_limits, the rate-limit auto-ban, custom_request_check, auth_verifier (the fallback of a route's verifiers), custom_response_modifier, on_error, cors_expose_headers, trusted_proxies and the rest all land on the fairing. The response processor shares the ban manager with the ban stage, so a return-rule ban is enforced by the next request.
The config is validated at construction (SecurityConfig::validate), and the stages a dynamic rule can change (geo, cloud, user agent, emergency mode, the penetration toggle, the rate limit and ban knobs) are read from the live config on every request, so a landed rule takes effect without a rebuild. A fairing built through the builder API (no config) keeps its static stages. Because the live rate-limit stage is built when the application ignites, builder calls made after from_security_config still reach it.
The telemetry agent is injected, or built from the config with the agent cargo feature. Without the feature, build a TelemetryHandler from the agent_* fields with guard-agent-rs's GuardAgentTelemetry and call GuardFairing::from_security_config_with(&config, &WiringOptions { agent: Some(agent), logfire_client: None }). With features = ["agent"] and no injected agent, enable_agent plus an agent_api_key builds the guard-agent-rs agent from the agent_* fields on the caller's tokio runtime; a failure degrades (agent_stats() reports degraded) unless agent_strict is set, which fails with GuardConfigError::Agent. The agent feeds the event bus, the metrics collector, agent_stats() and the dynamic rules. Wiring starts nothing: call fairing.telemetry() and its start() / stop(), and fairing.spawn_dynamic_rules() starts the poll loop (drop the handle to stop it). Body return_pattern rules buffer the response body (bounded in time by body_read_timeout, a read error or timeout answers 500) and re-emit it intact, so do not combine them with endless streaming responses.
Rocket streams request bodies to data guards, and a fairing can only peek at the first 512 bytes (Data::peek is capped at PEEK_BYTES). Scanning a truncated prefix as if it were the body would be a bypass vector, so the body view is scanned by GuardBody, which owns the data stream.
Bodies are buffered up to the cap configured on the fairing, defaulting to the engine's full-scan cap (DetectConfig::max_full_scan_bytes, 262 144 bytes in the ecosystem default):
let fairing = rocket_guard_rs::GuardFairing::with_defaults()
.with_body_cap(1_048_576);A body larger than the cap is rejected with 413 rather than forwarded unscanned. Rocket's own limits continue to apply inside handlers; a limit violation raised by Rocket's guards (for example Json) is also a 413 error outcome and therefore also gets the plain-text Payload too large body.
status::GuardStatus in managed state plus the status::guard_status route handler is the add_status_route mirror (fastapi-guard guard/status.py + HandlerInitializer.get_initialization_status): .manage(GuardStatus::new().with_cloud_table(table)).mount(status::DEFAULT_STATUS_PATH, routes![guard_status]) mounts GET /_guard/status, serving the cloud-provider status table ({"ready":..., "last_refreshed":<unix-seconds|null>, "entries":N} per provider from the live CloudIpTable) and the geo-ip component (null without a handler, {"configured":true} with the lookup trait only, the {ready, last_refreshed, entries} health snapshot with the IpInfoManager lifecycle manager). The maintenance trio lives on GuardFairing: reset() drops the rate-limit windows, refresh_cloud_ip_ranges() schedules one single-flight refresh through with_cloud_refresh_scheduler, and agent_stats() reports whether a telemetry sink is wired and whether its start degraded (see Config, agent and dynamic rules).
Rocket 0.5's WebSocket surface is the official rocket_ws crate, and websocket::WebSocketGuard guards it, ported from fastapi-guard's guard/websocket.py (make_guard_websocket): install the guard with .manage(make_guard_websocket(config)) and declare it on the rocket_ws routes it protects. A blocked handshake is rejected with 403 Forbidden pre-accept, carrying the close shape on x-guard-websocket-close / x-guard-websocket-close-reason (Starlette's WebSocketException denial, translated like the axum/actix siblings). The close codes are the reference's: 1008 Policy Violation for every policy denial (IP banned, IP not allowed, rate limit exceeded, unknown client address under fail-secure, suspicious activity) and 1013 Try Again Later for an engine malfunction (a panic in the check sequence, contained). The sequence: fail-secure unknown address, the ban arm, the is_ip_allowed gate + country arms (a whitelist match skips countries), the ws rate limit (skipped for whitelisted IPs), then the path/query/header penetration scan - an upgrade carries no body.
Frames after the upgrade are guarded too: WebSocketGuard::scan_frame(client_ip, &message) scans one text or binary message through the engine, feeds the shared violation store on a threat, and reports the close shape the channel answers with (WebSocketGuard::close_frame + stream_error); control frames pass untouched. WebSocketGuardConfig::with_violation_feed(ViolationFeed::new(counters, ban_config)) installs the shared-suspicious-counts store (the reference's shared suspicious_request_counts): a crossed threshold bans the address through the shared IpBanManager, and the next handshake's ban arm then rejects it - frame threats close the current connection with the suspicious-activity close exactly like the reference's single detection arm. Build the config from the handles the app already holds with WebSocketGuardConfig::new(default_config()) + the with_ip_gate / with_ip_banning / with_rate_limiting / with_country_rules / with_violation_feed / with_fail_secure / with_client_policy builders. The fairing itself passes upgrades through like any request; the guard is the per-route surface Rocket's model requires.
The Cargo.toml pins guard-core-engine and guard-core-rs at 4.3.2 (the floor) and carries paths pointing at the engine and facade crates inside a sibling guard-core-rs checkout so local builds and CI compile them from source. The unreleased parity work needs the engine's PathExclusions and resolve_client and the facade's Wiring, so it trains with the next engine release. CI checks out Guard-Core/guard-core-rs (see .github/workflows/ci.yml), mirroring the sibling adapter pattern in tower-guard-rs and actix-guard-rs.
Both halves of the sibling checkout are used: guard-core-engine for the detection entry point and the engine-side stages, and the guard-core-rs facade for the stage layers (the guard_core_rs::* stage types the fairing installs).
Every reference check the engine ships is installable on GuardFairing, and the fairing runs the installed set in the reference pipeline order: emergency mode, HTTPS enforcement, request logging, request size/content caps, required headers + authentication, referrer, custom validators, time windows, geo country blocking, cloud-provider blocking, user-agent filtering, bans, rate limiting, the custom-request check, and the response-side pass (behavioral return rules + security headers + CORS). The builder table lives in the crate docs, including the Rocket-specific seams (the ban arm before geo/cloud/user-agent, the tiers after, the HTTPS redirect rendered in on_response because Rocket catchers are 400-599 only).
- WebSocket guard (fairing half): the fairing scans an upgrade's path, query, and header views like any request, but the handshake rejection and the per-frame guard live in the
websocket::WebSocketGuardroute surface (described above), not the fairing; arocket_wsroute without the guard argument is not WS-guarded. - Pre-routing refusal of unmatched requests: Rocket resolves routes after the request fairings, so
route_resolution_strictcannot refuse inon_request. It is decided inon_responseinstead (see below): a request that matched no route answers500 Route resolution failed, and the404replaced proves no handler ran. - Request-body bound before Rocket routes: Rocket itself reads the first 14 bytes of every body before routing, so
body_read_timeoutbounds theGuardBodyread, not that initial peek.
route_resolution_strict: Rocket keeps the last route it tried on the request, soRequest::route()isNoneexactly when the router found no matching route (path, method, format and query all considered). The fairing checks that inon_response: with the knob set the answer is replaced with500 Route resolution failed(theroute_unresolvedevent and theon_blockhook fire, andcustom_error_responsesfor500applies); underpassive_modeit only logs. A route that matched and forwarded (for example a guard that forwards) is not unresolved.GuardFairing::with_route_resolution_strictsets it on a builder-built fairing.body_read_timeoutfor the request body:GuardBodybounds its body read with the config'sbody_read_timeout(seconds) and answers408 Request body read timed outwhen the body does not arrive in time. The unread body is never forwarded.- Route usage rules (
process_usage_rules) run after the whole pipeline passed, not before it: a request the pipeline or a guard refuses is not counted, and neither is an excluded, bypassed or unattributed (no client ip) request. Rocket has no single point just before the handler, soBlockGuardandGuardBodyrun the rules when they succeed (after the body scan forGuardBody), and a route without a guard argument runs them once inon_response.
redis: forwardsguard-core-rs/redis, soenable_redisplusredis_urlbuild the shared rate-limit and ban stores. Without it limits and bans stay per process.agent: builds the telemetry agent from theagent_*config fields (optionalguard-agent-rsand a tokio runtime handle). See the config section above.
- MSRV: 1.92 (matches guard-core-rs); edition 2024
- Requires a sibling
guard-core-rscheckout at../guard-core-rs
cargo check --all-targets
cargo test
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-depsCI (.github/workflows/ci.yml) runs the same checks on stable plus an MSRV 1.92 job, checking out guard-core-rs first so the path dependency resolves.
Dual-licensed under MIT or Apache-2.0.