Skip to content

About

Actix Guard adapter for Guard Core Rs

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Guard Core


Application-layer security middleware for [actix-web](https://github.com/actix/actix-web) 4, 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).

Crates.io version Docs Release License CI CodeQL

PagesBuildDeployment DocsUpdate last-commit

Actix Web Downloads

Website · Docs · Playground · Dashboard · Discord


Ecosystem

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.

Python

Package Role PyPI
guard-core Framework-agnostic security engine PyPI
guard-agent Telemetry agent PyPI
fastapi-guard FastAPI / Starlette adapter PyPI
flaskapi-guard Flask adapter PyPI
djapi-guard Django adapter PyPI
tornadoapi-guard Tornado adapter PyPI

Go

Go modules published via GitHub releases. Production-ready.

Package Role Release
guard-core-go Go engine release
nethttp-guard net/http adapter release
gin-guard Gin adapter release
echo-guard Echo (v4) adapter release
fiber-guard Fiber (v3) adapter release
guard-agent-go Telemetry agent release

PHP

Published on Packagist under the rennf93 vendor. Production-ready.

Package Role Packagist
guard-core-php PHP engine Packagist
laravel-guard Laravel adapter Packagist
symfony-guard Symfony adapter Packagist
psr15-guard PSR-15 adapter Packagist
slim-guard Slim 4 adapter Packagist
guard-agent-php Telemetry agent Packagist

TypeScript / JavaScript

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 npm
@guardcore/nestjs NestJS adapter npm
@guardcore/fastify Fastify adapter npm
@guardcore/hono Hono (edge) adapter npm
guardagent Telemetry agent npm

Rust

Published on crates.io. Production-ready.

Package Role crates.io
guard-core-engine Core engine crate crates.io
guard-core-rs Facade crate (consumer entry point) crates.io
actix-guard-rs Actix Web adapter crates.io
axum-guard-rs Axum adapter crates.io
tower-guard-rs Tower adapter crates.io
rocket-guard-rs Rocket adapter crates.io
guard-agent-rs Telemetry agent crates.io

AI Coding Agents

Package Role PyPI
guard-core-mcp MCP server: config validation, docs search, detection sandbox PyPI

Documentation

📚 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.


About

The guard ecosystem provides application-layer API security middleware across multiple languages and frameworks:

Per the ecosystem boundary rules, this crate holds framework glue only: every detection decision comes from the engine.

Usage

use actix_guard_rs::{default_config, GuardTransform};
use actix_web::{App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .wrap(GuardTransform::new(default_config()))
            .route("/", actix_web::web::to(|| async { "ok" }))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

The full crate documentation is in src/lib.rs (build it with cargo doc --open).

What it inspects

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 first, capped

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.

Engine surfaces (public config)

Every stateful decision and emission runs through the engine facade's rate-limit stage, configured through GuardTransform builders:

Surface Builder / idiom
Rate-limit tiers .with_route_tiers(resolver) (path -> Option<RouteRateLimits>; a RouteRateLimits request extension wins), .with_geo_handler(handler) (geo tiers)
Detection exclusions .with_detection_exclusions(config) (global excluded_detection_headers/params/body_fields, enabled_detection_categories, detection_scan_body); per route via a RouteDetectionExclusions request extension (a non-None route value replaces the global set; headers always merge)
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)
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 400/403/429 renders, auto-ban feeds suppressed)

Scan notes: the query string is scanned as parse_qsl-decoded per-parameter pairs (what makes excluded_detection_params functional), and excluded headers scan with their known false-positive categories suppressed (ssrf for address-chain values) instead of a blanket skip.

Responses

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
The distributed backend fails with redis_fail_open = false 503 Service Unavailable Redis rate limiting unavailable
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.

Engine panics are caught with catch_unwind on the worker thread, so a detected panic still produces a response instead of unwinding out of the request future. panic = "abort" in the release profile disables that recovery.

Rate limiting and IP banning

Two opt-in builder methods install the engine's stateful stage, mirroring the reference pipeline's order (ban check first, then the limiter, both before body buffering and detection):

use actix_guard_rs::{GuardTransform, 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 transform = actix_guard_rs::GuardTransform::new(actix_guard_rs::default_config())
    .with_rate_limiting(limiter)
    .with_ip_banning(manager, bans);
  • A rate-limit crossing answers 429 Too Many Requests with Retry-After: <window seconds>. With the limiter's enable_rate_limit_auto_ban on, every crossing counts one rate_limit violation toward the auto-ban engine; the response stays 429 and 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_config entry (or the flat auto_ban_threshold) bans on the spot, answering 403 Forbidden (IP has been banned). config.enable_ip_banning = false counts violations but never bans.
  • Both stages honor the exempt_ips contract: whitelisted and exempt IPs are never rate limited, never banned, and never counted; unattributed requests (no peer address) skip the stage but are still detection-screened.
  • The limiter, ban store, and violation counters are shared across all workers through an Arc, and the engine handles are cheaply clonable, so out-of-band handles (admin unban endpoints, stats) work alongside the installed transform.

Client resolution

The actix 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. 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. Bare layers default 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 decides X-Forwarded-Proto trust from the real peer and the real scheme, never from a header's claim.

Excluded paths

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 extension 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, the body cap, headers and auth, validators, the scan, 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).

Config, agent and dynamic rules

GuardTransform::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 transform. The transform clones per actix worker and every clone shares the one set of rate-limit, ban and cloud stores.

The agent Cargo feature builds the telemetry agent from the agent_* fields inside from_security_config (call it from a running actix system): a failure degrades, reported by agent_stats(), unless agent_strict is set, which answers GuardConfigError::Agent. You can also inject your own TelemetryHandler with GuardTransform::from_security_config_with(&config, &WiringOptions { agent: Some(agent), logfire_client: None }), which wins over the built one. The config is validated first (GuardConfigError::Config). The stages a dynamic rule can change (country, cloud, user agent, emergency mode, the detection toggle, the rate-limit and ban knobs) are read from the live config on every request, so a rule takes effect without rebuilding the transform; a transform built through the builders keeps its static stages. route_resolution_strict answers 500 Route resolution failed for a request no registered actix resource matches (read from the app's resource map, so it works for App::wrap; a log-only event under passive_mode). body_read_timeout bounds the request-body read, answered 408 Request body read timed out and never forwarded. Route usage and frequency rules run after the whole pipeline passed, so a blocked or unattributed request is not counted. The agent feeds the event bus, the metrics collector, agent_stats() and the dynamic rules. Wiring starts nothing: call transform.telemetry() and its start() / stop(), and transform.spawn_dynamic_rules() starts the poll loop (drop the handle to stop it). Body return_pattern rules buffer the forwarded 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.

Body cap

Request bodies are buffered so the engine can inspect them, and the buffer is bounded. The cap defaults to the engine's full-scan cap (DetectConfig::max_full_scan_bytes, 262 144 bytes in the ecosystem default) and is configurable:

let transform = actix_guard_rs::GuardTransform::with_defaults()
    .with_body_cap(1_048_576);

A body larger than the cap is rejected with 413 rather than forwarded unscanned: the engine would only ever see a truncated prefix, which would be a bypass vector.

Request rebuilding

actix Web consumes a request's payload as it is read, so the middleware buffers the body and hands the next service a rebuilt ServiceRequest (into_parts, buffer under the cap, from_parts around the buffered bytes). The wrapped service observes the request exactly as the client sent it, body included. The next service is shared through an Rc: actix Web builds its service tree per worker on single-threaded event loops, so this is free and never crosses threads.

WebSocket guard (upgrades)

websocket::WebSocketGuard is a Transform for the upgrade path, ported from fastapi-guard's guard/websocket.py: an upgrade request (Upgrade: websocket + an upgrade token in Connection) runs the reference check sequence before the handshake completes, and a blocked handshake is rejected with 403 Forbidden pre-accept, carrying the close shape on X-Guard-WebSocket-Close / X-Guard-WebSocket-Close-Reason (net/http has no pre-accept close frame; this is the HTTP-level translation of Starlette's WebSocketException denial). 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. Non-upgrade requests pass through untouched (the HTTP GuardTransform domain). Build it 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_fail_secure builders.

Status route

status::GuardStatus is the add_status_route mirror (fastapi-guard guard/status.py + HandlerInitializer.get_initialization_status): App::service(GuardStatus::new().with_cloud_table(table).with_geo_configured(true)) mounts GET /_guard/status (status::DEFAULT_STATUS_PATH), 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 GuardTransform: 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).

Engine dependency

The Cargo.toml floors guard-core-engine and guard-core-rs at 4.3.2 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. CI checks out Guard-Core/guard-core-rs (see .github/workflows/ci.yml), mirroring the sibling adapter pattern in tower-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 transform installs).

Stage surface (the reference 17-check pipeline, wired)

Every reference check the engine ships is installable on GuardTransform, and GuardService 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.

Not wired on purpose

  • WebSocket frames: the guard screens the upgrade handshake (the WebSocketGuard transform above); frames after the upgrade are not intercepted, there is no per-frame surface.
  • Distributed stores without the feature: the redis Cargo feature forwards guard-core-rs/redis. Without it enable_redis plus redis_url cannot build the shared stores, so limits and bans stay per process.

Development

  • MSRV: 1.92 (matches guard-core-rs); edition 2024
  • Requires a sibling guard-core-rs checkout at ../guard-core-rs
cargo check --all-targets
cargo test
cargo test --features agent
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps

CI (.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.

License

Dual-licensed under MIT or Apache-2.0.

About

Actix Guard adapter for Guard Core Rs

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages