Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions middleware/csrf-double-submit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions middleware/csrf-double-submit/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8080>, 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
114 changes: 114 additions & 0 deletions middleware/csrf-double-submit/src/main.rs
Original file line number Diff line number Diff line change
@@ -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#"<!DOCTYPE html>
<title>Double Submit Cookie</title>
<p>Signed in. The token is now bound to your session.</p>
<form method="post" action="/message">
<input type="hidden" name="csrf_token" value="{csrf}">
<input name="text" value="hello">
<button type="submit">Send</button>
</form>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{csrf}">
<button type="submit">Sign out</button>
</form>"#
)
} else {
format!(
r#"<!DOCTYPE html>
<title>Double Submit Cookie</title>
<p>Anonymous. The token is bound to a pre-session.</p>
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{csrf}">
<button type="submit">Sign in</button>
</form>"#
)
};

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<HttpResponse> {
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<HttpResponse> {
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<Message>) -> 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
}
12 changes: 12 additions & 0 deletions middleware/csrf-synchronizer/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions middleware/csrf-synchronizer/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8080>, 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
Loading