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" } 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