From 87e4465987158b33ec0e57ba561008cd84d0269a Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 19 Jan 2026 04:24:18 +0300 Subject: [PATCH 1/2] Add CSRF protection documentation and examples Introduces CSRF protection using the Double-Submit Cookie pattern to the README, library docs, and cookbook. Adds a dedicated recipe for CSRF protection, updates feature tables, and provides configuration and usage examples for both backend and frontend integration. --- README.md | 1 + crates/rustapi-extras/src/lib.rs | 17 +- docs/cookbook/src/SUMMARY.md | 2 + docs/cookbook/src/crates/rustapi_extras.md | 42 +++ docs/cookbook/src/recipes/csrf_protection.md | 270 +++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 docs/cookbook/src/recipes/csrf_protection.md diff --git a/README.md b/README.md index c087c18..fbe0c6d 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ rustapi-rs = { version = "0.1.9", features = ["jwt", "cors", "toon", "ws", "view | `jwt` | JWT authentication with `AuthUser` extractor | | `cors` | CORS middleware with builder pattern | | `rate-limit` | IP-based rate limiting | +| `csrf` | CSRF protection with Double-Submit Cookie pattern | | `toon` | LLM-optimized TOON format responses | | `ws` | WebSocket support with broadcast | | `view` | Template engine (Tera) for SSR | diff --git a/crates/rustapi-extras/src/lib.rs b/crates/rustapi-extras/src/lib.rs index 706caff..2b2d2c8 100644 --- a/crates/rustapi-extras/src/lib.rs +++ b/crates/rustapi-extras/src/lib.rs @@ -10,6 +10,7 @@ //! - `jwt` - JWT authentication middleware and `AuthUser` extractor //! - `cors` - CORS middleware with builder pattern configuration //! - `rate-limit` - IP-based rate limiting middleware +//! - `csrf` - CSRF protection using Double-Submit Cookie pattern //! - `config` - Configuration management with `.env` file support //! - `cookies` - Cookie parsing extractor //! - `sqlx` - SQLx database error conversion to ApiError @@ -17,11 +18,25 @@ //! - `extras` - Meta feature enabling jwt, cors, and rate-limit //! - `full` - All features enabled //! +//! ## CSRF Protection Example +//! +//! ```rust,ignore +//! use rustapi_core::RustApi; +//! use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken}; +//! +//! let config = CsrfConfig::new() +//! .cookie_name("csrf_token") +//! .header_name("X-CSRF-Token"); +//! +//! let app = RustApi::new() +//! .layer(CsrfLayer::new(config)); +//! ``` +//! //! ## Example //! //! ```toml //! [dependencies] -//! rustapi-extras = { version = "0.1", features = ["jwt", "cors", "insight"] } +//! rustapi-extras = { version = "0.1", features = ["jwt", "cors", "csrf"] } //! ``` #![warn(missing_docs)] diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 473c03e..7f6fb53 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -28,9 +28,11 @@ - [Part IV: Recipes](recipes/README.md) - [Creating Resources](recipes/crud_resource.md) - [JWT Authentication](recipes/jwt_auth.md) + - [CSRF Protection](recipes/csrf_protection.md) - [Database Integration](recipes/db_integration.md) - [File Uploads](recipes/file_uploads.md) - [Custom Middleware](recipes/custom_middleware.md) - [Real-time Chat](recipes/websockets.md) - [Production Tuning](recipes/high_performance.md) + diff --git a/docs/cookbook/src/crates/rustapi_extras.md b/docs/cookbook/src/crates/rustapi_extras.md index fab6570..fef6001 100644 --- a/docs/cookbook/src/crates/rustapi_extras.md +++ b/docs/cookbook/src/crates/rustapi_extras.md @@ -11,6 +11,7 @@ This crate is a collection of production-ready middleware. Everything is behind |---------|-----------| | `jwt` | `JwtLayer`, `AuthUser` extractor | | `cors` | `CorsLayer` | +| `csrf` | `CsrfLayer`, `CsrfToken` extractor | | `audit` | `AuditStore`, `AuditLogger` | | `rate-limit` | `RateLimitLayer` | @@ -25,6 +26,46 @@ let app = RustApi::new() .route("/", get(handler)); ``` +## CSRF Protection + +Cross-Site Request Forgery protection using the Double-Submit Cookie pattern. + +```rust +use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken}; + +// Configure CSRF middleware +let csrf_config = CsrfConfig::new() + .cookie_name("csrf_token") + .header_name("X-CSRF-Token") + .cookie_secure(true); // HTTPS only + +let app = RustApi::new() + .layer(CsrfLayer::new(csrf_config)) + .route("/form", get(show_form)) + .route("/submit", post(handle_submit)); +``` + +### Extracting the Token + +Use the `CsrfToken` extractor to access the token in handlers: + +```rust +#[rustapi_rs::get("/form")] +async fn show_form(token: CsrfToken) -> Html { + Html(format!(r#" + + "#, token.as_str())) +} +``` + +### How It Works + +1. **Safe methods** (`GET`, `HEAD`) generate and set the token cookie +2. **Unsafe methods** (`POST`, `PUT`, `DELETE`) require the token in the `X-CSRF-Token` header +3. If header doesn't match cookie → `403 Forbidden` + +See [CSRF Protection Recipe](../recipes/csrf_protection.md) for a complete guide. + ## Audit Logging For enterprise compliance (GDPR/SOC2), the `audit` feature provides a structured way to record sensitive actions. @@ -40,3 +81,4 @@ async fn delete_user( ); } ``` + diff --git a/docs/cookbook/src/recipes/csrf_protection.md b/docs/cookbook/src/recipes/csrf_protection.md new file mode 100644 index 0000000..0108c03 --- /dev/null +++ b/docs/cookbook/src/recipes/csrf_protection.md @@ -0,0 +1,270 @@ +# CSRF Protection + +Cross-Site Request Forgery (CSRF) protection for your RustAPI applications using the **Double-Submit Cookie** pattern. + +## What is CSRF? + +CSRF is an attack that tricks users into submitting unintended requests. For example, a malicious website could submit a form to your API while users are logged in, performing actions without their consent. + +RustAPI's CSRF protection works by: +1. Generating a cryptographic token stored in a cookie +2. Requiring the same token in a request header for state-changing requests +3. Rejecting requests where the cookie and header don't match + +## Quick Start + +```toml +[dependencies] +rustapi-rs = { version = "0.1", features = ["csrf"] } +``` + +```rust +use rustapi_rs::prelude::*; +use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken}; + +#[rustapi_rs::get("/form")] +async fn show_form(token: CsrfToken) -> Html { + Html(format!(r#" +
+ + +
+ "#, token.as_str())) +} + +#[rustapi_rs::post("/submit")] +async fn handle_submit() -> &'static str { + // If we get here, CSRF validation passed! + "Form submitted successfully" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let csrf_config = CsrfConfig::new() + .cookie_name("csrf_token") + .header_name("X-CSRF-Token"); + + RustApi::new() + .layer(CsrfLayer::new(csrf_config)) + .mount(show_form) + .mount(handle_submit) + .run("127.0.0.1:8080") + .await +} +``` + +## Configuration Options + +```rust +let config = CsrfConfig::new() + // Cookie settings + .cookie_name("csrf_token") // Default: "csrf_token" + .cookie_path("/") // Default: "/" + .cookie_domain("example.com") // Default: None (same domain) + .cookie_secure(true) // Default: true (HTTPS only) + .cookie_http_only(false) // Default: false (JS needs access) + .cookie_same_site(SameSite::Strict) // Default: Strict + + // Token settings + .header_name("X-CSRF-Token") // Default: "X-CSRF-Token" + .token_length(32); // Default: 32 bytes +``` + +## How It Works + +### Safe Methods (No Validation) + +`GET`, `HEAD`, `OPTIONS`, and `TRACE` requests are considered "safe" and don't modify state. The CSRF middleware: + +1. ✅ Generates a new token if none exists +2. ✅ Sets the token cookie in the response +3. ✅ **Does NOT validate** the header + +### Unsafe Methods (Validation Required) + +`POST`, `PUT`, `PATCH`, and `DELETE` requests require CSRF validation: + +1. 🔍 Reads the token from the cookie +2. 🔍 Reads the expected token from the header +3. ❌ If missing or mismatched → Returns `403 Forbidden` +4. ✅ If valid → Proceeds to handler + +## Frontend Integration + +### HTML Forms + +For traditional form submissions, include the token as a hidden field: + +```html +
+ + + +
+``` + +### JavaScript / AJAX + +For API calls, include the token in the request header: + +```javascript +// Read token from cookie +function getCsrfToken() { + return document.cookie + .split('; ') + .find(row => row.startsWith('csrf_token=')) + ?.split('=')[1]; +} + +// Include in fetch requests +fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ name: 'John' }) +}); +``` + +### Axios Interceptor + +```javascript +import axios from 'axios'; + +axios.interceptors.request.use(config => { + if (['post', 'put', 'patch', 'delete'].includes(config.method)) { + config.headers['X-CSRF-Token'] = getCsrfToken(); + } + return config; +}); +``` + +## Extracting the Token in Handlers + +Use the `CsrfToken` extractor to access the current token in your handlers: + +```rust +use rustapi_extras::csrf::CsrfToken; + +#[rustapi_rs::get("/api/csrf-token")] +async fn get_csrf_token(token: CsrfToken) -> Json { + Json(serde_json::json!({ + "csrf_token": token.as_str() + })) +} +``` + +## Best Practices + +### 1. Always Use HTTPS in Production + +```rust +let config = CsrfConfig::new() + .cookie_secure(true); // Cookie only sent over HTTPS +``` + +### 2. Use Strict SameSite Policy + +```rust +use cookie::SameSite; + +let config = CsrfConfig::new() + .cookie_same_site(SameSite::Strict); // Most restrictive +``` + +### 3. Combine with Other Security Measures + +```rust +RustApi::new() + .layer(CsrfLayer::new(csrf_config)) + .layer(SecurityHeadersLayer::strict()) // Add security headers + .layer(CorsLayer::permissive()) // Configure CORS +``` + +### 4. Rotate Tokens Periodically + +Consider regenerating tokens after sensitive actions: + +```rust +#[rustapi_rs::post("/auth/login")] +async fn login(/* ... */) -> impl IntoResponse { + // After successful login, a new CSRF token will be + // generated on the next GET request + // ... +} +``` + +## Testing CSRF Protection + +```rust +use rustapi_testing::{TestClient, TestRequest}; + +#[tokio::test] +async fn test_csrf_protection() { + let app = create_app_with_csrf(); + let client = TestClient::new(app); + + // GET request should work and set cookie + let res = client.get("/form").await; + assert_eq!(res.status(), StatusCode::OK); + + let csrf_cookie = res.headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap(); + + // Extract token value + let token = csrf_cookie + .split(';') + .next() + .unwrap() + .split('=') + .nth(1) + .unwrap(); + + // POST without token should fail + let res = client.post("/submit").await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + // POST with correct token should succeed + let res = client.request( + TestRequest::post("/submit") + .header("Cookie", format!("csrf_token={}", token)) + .header("X-CSRF-Token", token) + ).await; + assert_eq!(res.status(), StatusCode::OK); +} +``` + +## Error Handling + +When CSRF validation fails, the middleware returns a JSON error response: + +```json +{ + "error": { + "code": "csrf_forbidden", + "message": "CSRF token validation failed" + } +} +``` + +You can customize this by wrapping the layer with your own error handler. + +## Security Considerations + +| Consideration | Status | +|--------------|--------| +| Token in cookie | ✅ HttpOnly=false (JS needs access) | +| Token validation | ✅ Constant-time comparison | +| SameSite cookie | ✅ Configurable (Strict by default) | +| Secure cookie | ✅ HTTPS-only by default | +| Token entropy | ✅ 32 bytes of cryptographic randomness | + +## See Also + +- [JWT Authentication](jwt_auth.md) - Token-based authentication +- [Security Headers](../crates/rustapi_extras.md#security-headers) - Additional security layers +- [CORS Configuration](../crates/rustapi_extras.md#cors) - Cross-origin request handling From ec236ee9172a469071faab1716874f51f2b39d53 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Mon, 19 Jan 2026 04:25:31 +0300 Subject: [PATCH 2/2] Bump internal crate versions to 0.1.13 Updated Cargo.toml and Cargo.lock to reference version 0.1.13 for all internal RustAPI crates. This ensures consistency across the workspace and prepares for the next release. --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 22 +++++++++++----------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b8ba5f..4bf8e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,7 +378,7 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cargo-rustapi" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", @@ -3089,7 +3089,7 @@ dependencies = [ [[package]] name = "rustapi-bench" -version = "0.1.12" +version = "0.1.13" dependencies = [ "criterion", "serde", @@ -3099,7 +3099,7 @@ dependencies = [ [[package]] name = "rustapi-core" -version = "0.1.12" +version = "0.1.13" dependencies = [ "base64 0.22.1", "brotli 6.0.0", @@ -3138,7 +3138,7 @@ dependencies = [ [[package]] name = "rustapi-extras" -version = "0.1.12" +version = "0.1.13" dependencies = [ "base64 0.22.1", "bytes", @@ -3177,7 +3177,7 @@ dependencies = [ [[package]] name = "rustapi-jobs" -version = "0.1.12" +version = "0.1.13" dependencies = [ "async-trait", "chrono", @@ -3195,7 +3195,7 @@ dependencies = [ [[package]] name = "rustapi-macros" -version = "0.1.12" +version = "0.1.13" dependencies = [ "proc-macro2", "quote", @@ -3204,7 +3204,7 @@ dependencies = [ [[package]] name = "rustapi-openapi" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bytes", "http 1.4.0", @@ -3216,7 +3216,7 @@ dependencies = [ [[package]] name = "rustapi-rs" -version = "0.1.12" +version = "0.1.13" dependencies = [ "doc-comment", "rustapi-core", @@ -3236,7 +3236,7 @@ dependencies = [ [[package]] name = "rustapi-testing" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bytes", "futures-util", @@ -3256,7 +3256,7 @@ dependencies = [ [[package]] name = "rustapi-toon" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bytes", "futures-util", @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "rustapi-validate" -version = "0.1.12" +version = "0.1.13" dependencies = [ "async-trait", "http 1.4.0", @@ -3290,7 +3290,7 @@ dependencies = [ [[package]] name = "rustapi-view" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bytes", "http 1.4.0", @@ -3307,7 +3307,7 @@ dependencies = [ [[package]] name = "rustapi-ws" -version = "0.1.12" +version = "0.1.13" dependencies = [ "async-trait", "base64 0.22.1", @@ -4394,7 +4394,7 @@ dependencies = [ [[package]] name = "toon-bench" -version = "0.1.12" +version = "0.1.13" dependencies = [ "criterion", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5ef82cb..4a6beb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "0.1.12" +version = "0.1.13" edition = "2021" authors = ["RustAPI Contributors"] license = "MIT OR Apache-2.0" @@ -100,14 +100,14 @@ indicatif = "0.17" console = "0.15" # Internal crates -rustapi-core = { path = "crates/rustapi-core", version = "0.1.12", default-features = false } -rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.12" } -rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.12" } -rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.12", default-features = false } -rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.12" } -rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.12" } -rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.12" } -rustapi-view = { path = "crates/rustapi-view", version = "0.1.12" } -rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.12" } -rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.12" } +rustapi-core = { path = "crates/rustapi-core", version = "0.1.13", default-features = false } +rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.13" } +rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.13" } +rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.13", default-features = false } +rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.13" } +rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.13" } +rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.13" } +rustapi-view = { path = "crates/rustapi-view", version = "0.1.13" } +rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.13" } +rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.13" }