From 9dc86f8e704d8bd3aace5227f3d987768afab84b Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 8 Sep 2026 14:01:07 +0700 Subject: [PATCH] feat(middleware): add CSRF protection examples --- Cargo.lock | 48 ++++++++ Cargo.toml | 2 + middleware/csrf-double-submit/Cargo.toml | 11 ++ middleware/csrf-double-submit/README.md | 50 +++++++++ middleware/csrf-double-submit/src/main.rs | 114 +++++++++++++++++++ middleware/csrf-synchronizer/Cargo.toml | 12 ++ middleware/csrf-synchronizer/README.md | 53 +++++++++ middleware/csrf-synchronizer/src/main.rs | 129 ++++++++++++++++++++++ 8 files changed, 419 insertions(+) create mode 100644 middleware/csrf-double-submit/Cargo.toml create mode 100644 middleware/csrf-double-submit/README.md create mode 100644 middleware/csrf-double-submit/src/main.rs create mode 100644 middleware/csrf-synchronizer/Cargo.toml create mode 100644 middleware/csrf-synchronizer/README.md create mode 100644 middleware/csrf-synchronizer/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index c50f3c371..62d620182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,31 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-csrf-middleware" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f0e88e3cc12a9608ea60351d980bb9a3d0f5dad3c529c4d1990c238cf9c039" +dependencies = [ + "actix-http", + "actix-session", + "actix-utils", + "actix-web", + "base64 0.22.1", + "futures-util", + "hex", + "hmac 0.13.0", + "log", + "pin-project-lite", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "url", + "zeroize", +] + [[package]] name = "actix-files" version = "0.7.0" @@ -5447,6 +5472,29 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "middleware-csrf-double-submit" +version = "0.0.0" +dependencies = [ + "actix-csrf-middleware", + "actix-web", + "env_logger", + "log", + "serde", +] + +[[package]] +name = "middleware-csrf-synchronizer" +version = "0.0.0" +dependencies = [ + "actix-csrf-middleware", + "actix-session", + "actix-web", + "env_logger", + "log", + "serde", +] + [[package]] name = "middleware-encrypted-payloads" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 10692ef19..78d7af740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ members = [ "json/json-validation", "json/json", "json/jsonrpc", + "middleware/csrf-double-submit", + "middleware/csrf-synchronizer", "middleware/encrypted-payloads", "middleware/http-response", "middleware/http-to-https", diff --git a/middleware/csrf-double-submit/Cargo.toml b/middleware/csrf-double-submit/Cargo.toml new file mode 100644 index 000000000..2c181a6d4 --- /dev/null +++ b/middleware/csrf-double-submit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "middleware-csrf-double-submit" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +actix-csrf-middleware = "0.9" +actix-web.workspace = true +env_logger.workspace = true +log.workspace = true +serde.workspace = true diff --git a/middleware/csrf-double-submit/README.md b/middleware/csrf-double-submit/README.md new file mode 100644 index 000000000..5068e0d61 --- /dev/null +++ b/middleware/csrf-double-submit/README.md @@ -0,0 +1,50 @@ +# Middleware: CSRF (Double Submit Cookie) + +Stateless CSRF protection using [`actix-csrf-middleware`]. The token is stored in a cookie and mirrored back in a form field, and the middleware rejects any mutating request whose two copies do not agree. + +The token is an HMAC over a session (or pre-session) identifier rather than a bare random value, which binds it to one browser. Tokens are compared in constant time. See the [OWASP CSRF Prevention Cheat Sheet][owasp] for the pattern and its trade-offs. + +No session store is required. For the stateful variant, see [`csrf-synchronizer`](../csrf-synchronizer/). + +## Usage + +```sh +cd middleware/csrf-double-submit +cargo run +``` + +Open , sign in, then send a message. + +## Routes + +- [GET /](http://localhost:8080/) - renders a form carrying the token in a hidden `csrf_token` field. +- `POST /login` - issues a session id cookie and rotates the token from anonymous to authorized. +- `POST /logout` - tears down the session and the token. +- `POST /message` - protected. Reached only after the token is verified. + +Every mutating route is protected, including login and logout themselves. + +## Token rotation + +Rotate on authentication, or a token minted before sign-in stays valid after it. `rotate_csrf_after_login` expires the anonymous token and issues one bound to the new session; `rotate_csrf_after_logout` reverses it. + +Anonymous requests carry a `pre-session` cookie and an anonymous token (`CSRF-ANON`), which protect sign-in and registration. + +The application owns the session id cookie; the middleware only reads it. Write it with the same `Domain` the middleware is configured with, or the two scopes leave duplicate cookies the middleware cannot reconcile. + +## Try rejecting a request + +```sh +curl -i -X POST http://localhost:8080/message -d 'text=hello' +``` + +Returns `400 Bad Request` with `{"error":"csrf_token_missing"}` because no token was presented. Every rejection is a typed error rendered as JSON, recoverable through `ErrorHandlers` if you want a different shape. + +## Notes + +- The secret must be at least 32 bytes and identical across workers and restarts. Load it from configuration rather than hard-coding it. +- `with_secure(false)` is for plain HTTP in local development. Leave the default `true` behind TLS. +- Single-page apps can send the token in the `X-CSRF-Token` header instead of a form field, which skips body buffering entirely. + +[`actix-csrf-middleware`]: https://crates.io/crates/actix-csrf-middleware +[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html diff --git a/middleware/csrf-double-submit/src/main.rs b/middleware/csrf-double-submit/src/main.rs new file mode 100644 index 000000000..4ae729138 --- /dev/null +++ b/middleware/csrf-double-submit/src/main.rs @@ -0,0 +1,114 @@ +use std::io; + +use actix_csrf_middleware::{ + CsrfMiddleware, CsrfMiddlewareConfig, CsrfRequestExt, CsrfToken, DEFAULT_SESSION_ID_KEY, +}; +use actix_web::{ + App, HttpRequest, HttpResponse, HttpServer, + cookie::{Cookie, SameSite}, + http::header::LOCATION, + middleware, web, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Message { + text: String, +} + +fn page(csrf: &str, signed_in: bool) -> HttpResponse { + let body = if signed_in { + format!( + r#" +Double Submit Cookie +

Signed in. The token is now bound to your session.

+
+ + + +
+
+ + +
"# + ) + } else { + format!( + r#" +Double Submit Cookie +

Anonymous. The token is bound to a pre-session.

+
+ + +
"# + ) + }; + + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(body) +} + +async fn index(req: HttpRequest, csrf: CsrfToken) -> HttpResponse { + page(&csrf.0, req.cookie(DEFAULT_SESSION_ID_KEY).is_some()) +} + +async fn login(req: HttpRequest) -> actix_web::Result { + let session_id = "example-session-id"; + + let mut resp = HttpResponse::SeeOther(); + resp.cookie( + Cookie::build(DEFAULT_SESSION_ID_KEY, session_id) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .finish(), + ); + + req.rotate_csrf_after_login(session_id, &mut resp)?; + + resp.append_header((LOCATION, "/")); + + Ok(resp.finish()) +} + +async fn logout(req: HttpRequest) -> actix_web::Result { + let mut resp = HttpResponse::SeeOther(); + + req.rotate_csrf_after_logout(&mut resp)?; + + resp.append_header((LOCATION, "/")); + + Ok(resp.finish()) +} + +async fn message(form: web::Form) -> HttpResponse { + HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .body(format!("accepted: {}", form.text)) +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let secret = b"example-secret-key-of-at-least-32-bytes"; + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { + let csrf = CsrfMiddlewareConfig::double_submit_cookie(secret).with_secure(false); + + App::new() + .wrap(CsrfMiddleware::new(csrf)) + .wrap(middleware::Logger::default()) + .route("/", web::get().to(index)) + .route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)) + .route("/message", web::post().to(message)) + }) + .workers(2) + .bind(("127.0.0.1", 8080))? + .run() + .await +} diff --git a/middleware/csrf-synchronizer/Cargo.toml b/middleware/csrf-synchronizer/Cargo.toml new file mode 100644 index 000000000..682e15fca --- /dev/null +++ b/middleware/csrf-synchronizer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "middleware-csrf-synchronizer" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +actix-csrf-middleware = { version = "0.9", features = ["session"] } +actix-session = { workspace = true, features = ["cookie-session"] } +actix-web.workspace = true +env_logger.workspace = true +log.workspace = true +serde.workspace = true diff --git a/middleware/csrf-synchronizer/README.md b/middleware/csrf-synchronizer/README.md new file mode 100644 index 000000000..c940c526d --- /dev/null +++ b/middleware/csrf-synchronizer/README.md @@ -0,0 +1,53 @@ +# Middleware: CSRF (Synchronizer Token) + +Stateful CSRF protection using [`actix-csrf-middleware`] with [`actix-session`]. The token lives server-side in the session, and the middleware compares it in constant time against the copy the client presents. + +Unlike the double submit variant the token is never readable by client scripts, at the cost of requiring a session store. See the [OWASP CSRF Prevention Cheat Sheet][owasp] for the trade-off. + +For the stateless variant, see [`csrf-double-submit`](../csrf-double-submit/). + +## Usage + +```sh +cd middleware/csrf-synchronizer +cargo run +``` + +Open , sign in, then send a message. + +## Routes + +- [GET /](http://localhost:8080/) - renders a form carrying the token in a hidden `csrf_token` field. +- `POST /login` - issues a session id cookie and rotates the token from anonymous to authorized. +- `POST /logout` - tears down the session and the token. +- `POST /message` - protected. Reached only after the token is verified. + +Every mutating route is protected, including login and logout themselves. + +## Token rotation + +Rotate on authentication, or a token minted before sign-in stays valid after it. `rotate_csrf_after_login` replaces the anonymous token with one bound to the new session; `rotate_csrf_after_logout` purges it. + +## Cookie names + +`actix-session` defaults its cookie to `id`, the same name as the middleware's default `session_id_cookie_name`. Left colliding, every request is classified authorized from the first response onward. This example names the session cookie `session`. + +## Try rejecting a request + +```sh +curl -i -X POST http://localhost:8080/message -d 'text=hello' +``` + +Returns `400 Bad Request` with `{"error":"csrf_token_missing"}` because no token was presented. + +## Notes + +- **Middleware order matters.** `SessionMiddleware` is wrapped after `CsrfMiddleware`, which makes it the outer layer. actix applies `wrap` from the inside out, and the session must be available by the time the CSRF middleware runs. +- `Key::generate()` is called once outside `HttpServer::new`. Generating it inside the closure would give each worker a different key and invalidate sessions between requests. Load a fixed key from configuration in production. +- The secret must be at least 32 bytes and identical across workers and restarts. +- `with_secure(false)` and `cookie_secure(false)` are for plain HTTP in local development. Leave both at their secure defaults behind TLS. +- This example uses `CookieSessionStore` to stay self-contained. Any `actix-session` backend works. + +[`actix-csrf-middleware`]: https://crates.io/crates/actix-csrf-middleware +[`actix-session`]: https://crates.io/crates/actix-session +[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html diff --git a/middleware/csrf-synchronizer/src/main.rs b/middleware/csrf-synchronizer/src/main.rs new file mode 100644 index 000000000..22f1f4f16 --- /dev/null +++ b/middleware/csrf-synchronizer/src/main.rs @@ -0,0 +1,129 @@ +use std::io; + +use actix_csrf_middleware::{ + CsrfMiddleware, CsrfMiddlewareConfig, CsrfRequestExt, CsrfToken, DEFAULT_SESSION_ID_KEY, +}; +use actix_session::{SessionMiddleware, storage::CookieSessionStore}; +use actix_web::{ + App, HttpRequest, HttpResponse, HttpServer, + cookie::{Cookie, Key, SameSite}, + http::header::LOCATION, + middleware, web, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Message { + text: String, +} + +fn page(csrf: &str, signed_in: bool) -> HttpResponse { + let body = if signed_in { + format!( + r#" +Synchronizer Token +

Signed in. The token is held server-side for this session.

+
+ + + +
+
+ + +
"# + ) + } else { + format!( + r#" +Synchronizer Token +

Anonymous. The token is held under a pre-session key.

+
+ + +
"# + ) + }; + + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(body) +} + +async fn index(req: HttpRequest, csrf: CsrfToken) -> HttpResponse { + page(&csrf.0, req.cookie(DEFAULT_SESSION_ID_KEY).is_some()) +} + +async fn login(req: HttpRequest) -> actix_web::Result { + let session_id = "example-session-id"; + + let mut resp = HttpResponse::SeeOther(); + resp.cookie( + Cookie::build(DEFAULT_SESSION_ID_KEY, session_id) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .finish(), + ); + + req.rotate_csrf_after_login(session_id, &mut resp)?; + + resp.append_header((LOCATION, "/")); + + Ok(resp.finish()) +} + +async fn logout(req: HttpRequest) -> actix_web::Result { + let mut resp = HttpResponse::SeeOther(); + resp.cookie( + Cookie::build(DEFAULT_SESSION_ID_KEY, "") + .path("/") + .max_age(actix_web::cookie::time::Duration::seconds(0)) + .finish(), + ); + + req.rotate_csrf_after_logout(&mut resp)?; + + resp.append_header((LOCATION, "/")); + + Ok(resp.finish()) +} + +async fn message(form: web::Form) -> HttpResponse { + HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .body(format!("accepted: {}", form.text)) +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let secret = b"example-secret-key-of-at-least-32-bytes"; + let session_key = Key::generate(); + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { + let csrf = CsrfMiddlewareConfig::synchronizer_token(secret).with_secure(false); + + let session = + SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone()) + .cookie_name("session".to_owned()) + .cookie_secure(false) + .build(); + + App::new() + .wrap(CsrfMiddleware::new(csrf)) + .wrap(session) + .wrap(middleware::Logger::default()) + .route("/", web::get().to(index)) + .route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)) + .route("/message", web::post().to(message)) + }) + .workers(2) + .bind(("127.0.0.1", 8080))? + .run() + .await +}