Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Which security layer to use, and when

Rullst has two places for security code, and some of their controls look alike:

  • rullst-core (rullst::security) owns the runtime baseline: secure headers with a CSP nonce, CORS, a small WAF, double-submit CSRF, optional response PII masking, trusted-proxy client resolution, exact machine endpoints and tenant context. Server mounts the baseline for you in staging and production.
  • rullst-security (rullst::security_runtime, or the rullst_security crate that generated applications already depend on) is a toolbox of opt-in layers and helpers: RASP, response DLP, configurable headers, honeypots, CSWSH, JSON schema guards, rate limiters, the login jail, MFA, RBAC, audit chains, field encryption, SRI and more. Nothing in it protects traffic until you mount or call it.

This page tells you which one to pick for each job, gives one recommended production stack and states what each layer does not protect against. It summarizes the code in rullst-core/src/security/ and rullst-security/src/; the security architecture page holds the deeper contracts, and both crates are in the Core maturity tier.

The short answer

  1. Run your application through Server and configure [security] in Rullst.toml. In staging and production that gives you headers, CORS, the WAF and CSRF without extra code.
  2. Behind a reverse proxy, set Server::trusted_proxies (or [security] trusted_proxies) to your proxy networks only.
  3. Add from rullst-security only what your application needs: the honeypot and response DLP for every app, the CSWSH guard on WebSocket routes, a JSON schema guard on JSON write routes, and the login jail, MFA, RBAC and audit chain inside your handlers.
  4. Do not stack two layers that do the same job and expect double protection. The pairs below explain which one wins and what each one misses.

What generated applications already have

StarterMounted for youNot mounted
Every starterServer applies the Core baseline in staging and production: secure headers, CORS (only when cors_allow_origins is set), WAF and CSRF. PII masking stays off unless enable_pii_masking = true. In development the baseline adds only CORS (and machine-endpoint authentication when configured) and a report-only WAF that logs what it would refuse.No rullst-security middleware layer (RASP, DLP, SecureHeadersLayer, honeypot, CSWSH, schema guard) is mounted by any starter. No global rate limit or Traffic Shield is configured. Trusted proxies are configured only when cargo rullst deploy adds its Caddy proxy address to [security] trusted_proxies.
Blank (full stack), SaaS (and the LMS, ERP and Blog examples)Core csrf_middleware and headers_middleware on the router too, so development behaves like production.—
API (Blank JSON API)Core headers_middleware on the router. The example write route POST /api/messages is an exact machine endpoint (Server::with_machine_endpoints): clients send Authorization: Bearer <API_TOKEN> (a random value in the generated .env), requests with cookies are refused, and startup fails without API_TOKEN.No CSRF layer on the router. In staging and production the Server baseline requires CSRF on every other write route, so add each new JSON write route to machine_endpoints() in src/main.rs (a {id} segment covers routes such as PUT /api/posts/{id}), or send the double-submit token from a browser. make:controller --api prints these lines.
Portfolio exampleOnly the Server baseline (staging and production).No router-level layers in development.
SaaS (and the LMS example)A Core token-bucket limit on the credential routes; the SaaS [security] section exempts the exact signed billing webhook path from CSRF. The LMS example uses rullst_security::{UserContext, RbacGuard}.Login jail, MFA (add it with make:mfa), audit chain.

Every generated Cargo.toml already lists rullst-security, so adding a layer needs no new dependency.

Since 13.0 every generated project also has src/security_tests.rs, which cargo test runs offline against the project’s own router() wrapped in the staging/production baseline (apply_security_baseline, as Server composes it). It checks the security headers, that a write without the CSRF token is refused and one with it passes, and that the WAF refuses an injection probe but accepts the prose “Please select an option”. SaaS adds the sign-in rate limit and the API starter the bearer token of its machine endpoint; the LMS example also checks the owner of its lesson routes. Keep these tests passing as you change routes; they do not replace your own authorization tests.

From the outermost layer to your handler:

#LayerOwnerHow you enable it
1Trusted-proxy client resolutionCoreServer::trusted_proxies or [security] trusted_proxies
2Secure headers and CSP nonceCore baselineAutomatic in staging/production; tune [security] csp and coep
3CORS allowlistCore baseline[security] cors_allow_origins
4WAFCore baselineAutomatic in staging/production
5CSRF (and exact machine endpoints)Core baselineAutomatic; Server::with_machine_endpoints for API, webhook and mTLS routes
6Readiness and drain, Traffic Shield, per-peer rate limitCore Serverwith_lifecycle, shield, rate_limit
7Honeypot trap paths and peer bansrullst-securityHoneypotLayer
8Response secret maskingrullst-securityDlpResponseLayer
9Per-route guardsrullst-securitycswsh_guard_middleware on WebSocket routes, json_schema_guard_middleware on JSON write routes
10Session, authentication, tenant and authorizationYour applicationYour auth middleware, TenantContext, RbacGuard
11Inside handlersBothLoginGuard, a RedisRateLimiter for credential routes, TOTP and recovery codes, AuditChain, FieldEncryptor

Server composes layers 1 to 6 itself (see rullst-core/src/server/stack.rs); you add 7 to 11 to your router. The canonical production preset is the full machine-readable ordering contract, including request-body limits, request IDs and tracing.

use axum::{Extension, Router, middleware::from_fn};
use rullst::security::TrustedProxyConfig;
use rullst::{RateLimitConfig, RateLimiter, Server};
use rullst_security::{
    CswsPolicy, DlpResponseLayer, HoneypotLayer, HoneypotState, cswsh_guard_middleware,
};

/// `pages` holds the application routes, with session, authentication and
/// RBAC checks inside them; `live` holds the WebSocket upgrade routes.
async fn serve(pages: Router, live: Router) -> Result<(), Box<dyn std::error::Error>> {
    // Layer 9: reject cross-site WebSocket upgrades.
    let live = live
        .route_layer(from_fn(cswsh_guard_middleware))
        .layer(Extension(CswsPolicy::try_new(["https://app.example.com"])?));

    let app = pages
        .merge(live)
        // Layer 8: mask private keys, AWS key IDs and database URL passwords.
        .layer(DlpResponseLayer)
        // Layer 7: refuse trap paths such as /.env and ban direct scanners.
        .layer(HoneypotLayer::new(HoneypotState::default()));

    Server::new(app.into())
        // Layer 1: accept forwarded client addresses only from your proxies.
        .trusted_proxies(TrustedProxyConfig::new(["10.0.0.0/8"])?)
        // Layer 6: process-local token bucket per IPv4 address or IPv6 /64.
        .rate_limit(RateLimiter::new(RateLimitConfig::per_minute(600.0)))
        .run(3000)
        .await?;
    Ok(())
}

The snippet is marked ignore: it shows the shape and the real API names, but the book does not compile it. Replace the example origin and proxy network with your own. rullst::RateLimiter (Core token bucket) and rullst_security::RateLimiter (fixed window) are different types. The WAF, RASP and schema guards buffer at most 1–2 MiB of a body themselves; set an explicit limit on upload routes too (for example axum’s DefaultBodyLimit).

Leave these out unless you have a specific reason:

  • RaspSecurityLayer overlaps the Core WAF and buffers the same request body a second time. Add it when you need its extra signatures (cloud metadata hosts, JNDI lookups and every non-credential header) and accept more false positives.
  • Core PII masking rewrites e-mail addresses and long digit runs in every textual response, including ones your users are meant to see. Prefer redacting fields in your own serializers.
  • SecureHeadersLayer inside Server only when you need values that [security] cannot express: the Core baseline then keeps every header the layer chose or omitted (see below).

Overlapping layers compared

Secure headers: Core baseline vs SecureHeadersLayer

Core headers_middlewarerullst_security::SecureHeadersLayer
Mounted byServer in staging/production; most blueprints also mount it on the routerYou, as a Tower layer
Configuration[security] csp (template with {NONCE}), coep and the hsts_* settings; other values fixedSecureHeadersConfig: every header value replaceable or omitted (None), CSP template or static CSP
HeadersHSTS (one year with includeSubDomains, no preload, by default), X-Frame-Options: DENY, nosniff, X-XSS-Protection: 0, Referrer-Policy, Permissions-Policy, COOP, CORP, COEP, CSPThe same set, from the config; the default HSTS value is the same
Existing valuesAdds each header only when the response has none; leaves all of them alone after an inner SecureHeadersLayerReplaces the handler’s values with its configured ones
Cache-ControlAdds no-store when the handler set noneNot touched
CSP nonceCspNonce request extensionSame CspNonce, reused when one already exists

Both layers reuse one CspNonce per request, so templates render a nonce that matches the final header. The Core baseline is a fallback: it fills in a security header only when the handler or an inner layer has not set it, so an explicit value set by the application wins over the baseline. SecureHeadersLayer marks its responses with rullst::security::SecurityHeadersApplied; when the Core baseline (outermost inside Server in staging and production) sees that marker it leaves every security header to the layer, so the headers SecureHeadersConfig sets to None stay absent. It still adds Cache-Control: no-store when no cache policy was set. The trade-off: a weaker value that a handler sets on purpose or by mistake (for example Referrer-Policy: unsafe-url or a CSP with 'unsafe-inline') is no longer replaced, so review the headers your handlers set. Use [security] settings to tune a Server application, and SecureHeadersLayer when you need per-header values or omissions, or for a plain Axum router that you serve without Server. An exact Referrer-Policy: no-referrer from a handler survives both layers, normalized to one value. CspSecurityLayer (in sanitizer) is a smaller variant: default CSP, X-Frame-Options, nosniff and Referrer-Policy only; it does not set the marker, so the Core baseline keeps those four and adds the rest.

Strict-Transport-Security

The Core baseline sends Strict-Transport-Security: max-age=31536000; includeSubDomains unless the response already has the header. Three [security] settings choose another value:

[security]
hsts_max_age_seconds = 63072000  # default 31536000 (one year); 0 clears the policy
hsts_include_subdomains = true   # default true
hsts_preload = true              # default false

includeSubDomains makes browsers refuse plain HTTP on every subdomain of the host for the whole max-age, so leave it on only when all of them serve HTTPS. preload asks to be listed in the HSTS preload list built into browsers once the domain is submitted there, and leaving that list takes months, so it is opt-in. Startup fails when hsts_preload is set without hsts_include_subdomains or with a max-age below 31536000, the list’s own requirements. A handler that sets the header, or SecureHeadersConfig::hsts in SecureHeadersLayer, still wins over these settings. 13.0.0-alpha.1 and earlier always sent max-age=63072000; includeSubDomains; preload.

Not covered: headers reduce browser-side risks (framing, MIME sniffing, inline script injection); they do not fix an XSS bug in a page that allows 'unsafe-inline' or a trusted script host that serves attacker content, and HSTS has no effect until the first HTTPS response reaches the browser.

Request inspection: Core WAF vs RASP

Core waf_middlewarerullst_security::RaspSecurityLayer
Mounted byServer in staging/production; in development a report-only variant that logs instead of refusingYou
SignaturesInjection structure, not keywords. SQL: a quote followed by or/and and a comparison or by a comment (' or '1'='1, admin'--), a ; followed by a statement (; drop table, ; delete from, ; select *), union [all] select, and probes (sleep(, benchmark(, waitfor delay, pg_sleep(, information_schema, @@version, xp_cmdshell, load_file(, into outfile). Shell: ;, |, &&, a backtick or $( followed by a command name (sh, bash, cat, ls, id, curl, wget, nc, rm, python…). XSS (<script, javascript:, onload=, onerror=, document.cookie) and traversal (../, ..\, /etc/passwd, win.ini) as substringsMore specific phrases: SQL (union select, ' or '1'='1, sleep(, information_schema…), traversal, SSRF hosts (169.254.169.254, metadata.google.internal), shell (cmd.exe, /bin/sh…), JNDI (${jndi:…)
Where it looksDecoded path (traversal only), query, Referer, each cookie pair, User-Agent against a configurable blocklist (AI and SEO crawlers by default)Full request target, every header except Cookie and Authorization, body; JSON keys and strings after decoding
Bodiestext/*, JSON, XML, URL-encoded forms; identity encoding only (others get 415); at most 1 MiB (413); invalid UTF-8 gets 400Same media types and limits
DecodingOne percent-decoding pass, then lowercase with whitespace runs collapsed, checked with and without /* … */ comments; JSON bodies are checked key by key and string by stringRaw text plus one percent-decoding pass
TelemetryNoneSecurityStore interception counters and events

The Core WAF is a coarse baseline. Ordinary text that names SQL or shell words (“please select an option”, “delete my account”, “curl the API with your token”) passes; only injection syntax around those words is refused. Parameterized SQL is the real defense against SQL injection, and shell-free process APIs against command injection.

Seeing and accepting WAF false positives

The WAF refuses requests only in staging and production. In development Server mounts the same rules in report-only mode: each request they would refuse passes, and a tracing warning from rullst::security::waf names the rule and the path, for example rule="body-signature" path="/lessons/answers". The warning never includes the query, headers or body. The rules are user-agent-blocklist, path-traversal, query-signature, referer-signature, cookie-signature, body-signature, body-encoding, body-too-large and body-not-utf8.

A route whose body must carry text the signatures refuse, such as code samples in a programming lesson (../, javascript:, document.cookie, | grep), can be exempted by exact path:

[security]
waf_body_exempt_paths = ["/lessons/answers"]

The WAF then neither reads nor inspects bodies sent to that exact path, so it also stops checking their encoding and size; set a body limit on the route (DefaultBodyLimit). Its path, query string, cookies, Referer and User-Agent stay inspected, and CSRF still applies. Entries must be exact absolute paths: wildcards, route parameters ({id}, :id), queries, fragments, dot segments and duplicates fail validation, so the server refuses to start. Each entry is a deliberate risk acceptance for that route: the handler alone (parameterized SQL, output encoding, validation) must make the body safe.

Not covered by either: binary bodies, multipart/form-data file contents and file names, multipart text fields that are not read with rullst::uploads::Multipart (which applies the Core WAF signatures to each field it reads; RASP does not inspect multipart at all; see form uploads), double-encoded or otherwise obfuscated payloads (HTML entities, Unicode escapes outside JSON strings, and in RASP SQL comments between keywords), payloads split across fields, injections that need no quote, ; or union (a bare numeric 1 or 1=1), and every attack class without a listed signature. Both still produce false positives: the Core WAF refuses text in which a shell metacharacter precedes a command name (a Markdown cell | id |, inline code such as `ls`) or a quote precedes or and a comparison, and RASP refuses any field that contains ../ or /bin/sh. Parameterized SQL, output encoding, typed validation and URL allowlists remain the actual defenses.

Response masking: Core PII masking vs DLP

Both layers inspect responses only; neither looks at requests. (Prompt PII masking for LLM calls lives in rullst-ai.)

Core pii_masking_middlewarerullst_security::DlpResponseLayer
Enabled by[security] enable_pii_masking = true (staging/production)You
FindsE-mail addresses; 13–19 digit card-like runs (digits with at most two spaces or hyphens between them; no Luhn check)Complete PEM private-key blocks (PKCS#8, RSA, EC, DSA, OpenSSH, OpenPGP), AKIA access-key IDs, passwords in postgres, postgresql, mysql, redis and rediss URLs
Media typestext/* except text/event-stream, JSON (masked only inside string values), XML, application/javascripttext/* except text/event-stream, JSON, XML, YAML, application/javascript and its aliases
BoundsFixed-size, identity-encoded bodies up to 2 MiB; streams, unknown sizes, compressed and larger bodies pass unchanged; HEAD, 204 and 304 skippedSame
206 Partial ContentA range that would change becomes 502 with no-store; a clean range passesSame
multipart/byterangesSplit into parts (at most 256 parts, 8 KiB of headers per part); withheld with 502 when a textual part would change or the body cannot be splitSame

Not covered: a value split across separately requested ranges, any compressed or streamed response, other secret formats (temporary ASIA keys, AWS secret keys, API tokens, JWTs, passwords outside a database URL), PII other than e-mails and card-like numbers, and values encoded in a way the pattern does not match. Core masking also hides legitimate phone numbers, order numbers and e-mails. Neither layer stops a leak through logs, files or another channel; use redact_secrets for log fields and keep secrets out of responses in the first place.

Browser and abuse controls

ControlOwnerReducesMechanismDoes not protect against
Double-submit CSRFCoreCross-site form and fetch writes using ambient cookiesrullst_csrf cookie must match X-CSRF-Token or the _token form field, compared in constant time. A GET for a static-looking path (/robots.txt, /sitemap.xml, /favicon.ico, /static/*, or a .txt, .xml, .ico, .json, .css, .js, .png, .jpg, .jpeg, .svg, .webp or .wasm suffix) never mints the token; it only reuses an existing cookie’sState changes in GET handlers; a sibling subdomain that can set cookies; an XSS bug that reads the token; WebSocket upgrades
Exact machine endpointsCoreWebhook and API routes that must skip browser CSRFExact method and path, authenticated (bearer digest, signed-webhook or mTLS verifier) before the CSRF exemption appliesA weak verifier you supply; replay unless your verifier checks freshness
Trusted-proxy resolutionCoreSpoofed X-Forwarded-For choosing the client identityForwarded headers read only from listed proxy networks, right to leftA listed proxy that forwards client headers unchanged; any host inside a listed network
Tenant contextCoreClient-chosen tenant headersOnly tenant context inserted by your authentication middleware is usedMissing ownership checks in handlers
Rate limit (Server::rate_limit)CorePer-peer request floodsIn-memory token bucket per IPv4 address or IPv6 /64, at most 100,000 keys (LRU)Multiple instances (each has its own budget), botnets, network-level DDoS
RedisRateLimiterrullst-security (redis-rate-limit feature; facade security-redis)Budgets shared across instancesAtomic fixed-window Lua script in RedisRedis outages and failover semantics; call require_distributed() so a mock URL fails at startup
rate_limit_middleware, RateLimiterrullst-securityPer-peer floods in one processFixed window, 16,384 keys, fails closed for new keys when fullSame as the Core limiter; prefer one limiter per policy
LoginGuardrullst-securityPassword guessing on one identityProgressive delay and an in-memory jail per hashed identityDistributed guessing across identities; restarts and other instances (state is local)
Honeypot (HoneypotLayer)rullst-securityAutomated scanners probing well-known pathsExact trap paths; direct hits ban the socket peer for 15 minutes by defaultScanners that avoid the paths or rotate addresses; bans are per process and per exact IP
deception_trap_middlewarerullst-securityThe same, without bansGlobal registry of exact trap paths; 403 plus telemetryAnything beyond recording the probe
CSWSH guardrullst-securityCross-site WebSocket hijacking with ambient cookiesExact Origin allowlist, or same host/port/scheme when no allowlist is set; missing Origin rejected by defaultNon-browser clients (they can send any Origin); authentication of the socket itself
TOTP and recovery codesrullst-securityStolen passwordsRFC 6238 TOTP, ±1 step; verify_totp_step_after rejects replayed steps; HMAC-verified single-use recovery codesReal-time phishing proxies; replay unless you persist the last accepted step
AuditChainrullst-securitySilent edits to stored audit recordsHMAC-SHA256 chain over sequence, time, fields and predecessorDeleting the newest records without an external checkpoint; a stolen HMAC key
FieldEncryptorrullst-securityReadable sensitive columns in a stolen database copyAES-256-GCM with AAD and key IDsAn attacker with the key or with application access; key custody is yours
SRI tagsrullst-securityA compromised CDN serving altered JS or CSSSHA-384 integrity attributesAssets that legitimately change without regenerating the tag
JSON schema guardrullst-securityOversized, deep, duplicate-key or off-schema JSONSize (2 MiB) and depth (32) limits, duplicate-key rejection, optional JSON Schema 2020-12 policyBusiness-rule validation; the global guard passes non-JSON content types through

What none of these layers do

  • Authorization. No layer knows who owns a record. Check ownership and roles in every protected handler (RbacGuard::authorize_owner_or_role) and test the denial.
  • Injection-proof data access. Use SQLx parameters or sanitize_identifier; WAF and RASP signatures are a backstop, not a parser.
  • Network-level protection. Volumetric DDoS, TLS termination, firewalling and host hardening belong to your proxy, provider and operating system.
  • Shared state. Bans, jails and most counters are per process. Multiple instances need the Redis limiter or your own shared store.
  • Certification. Mounting these layers does not make an application OWASP-, PCI- or SOC 2-compliant.

To collect static evidence of which of these layers a project mounts (headers and CSP, CSRF, cookie attributes, rate limiting on credential routes), run cargo rullst audit --report; the security report guide explains each check and its OWASP ASVS 5.0 Level 1 mapping. The external audit kit packages scope, threat models, a sample application and tooling for a third-party reviewer.

See the rullst-security crate page for module details and the security architecture for the deployment checklist.