Skip to content

Commit 87e4465

Browse files
committed
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.
1 parent efe762b commit 87e4465

5 files changed

Lines changed: 331 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ rustapi-rs = { version = "0.1.9", features = ["jwt", "cors", "toon", "ws", "view
204204
| `jwt` | JWT authentication with `AuthUser<T>` extractor |
205205
| `cors` | CORS middleware with builder pattern |
206206
| `rate-limit` | IP-based rate limiting |
207+
| `csrf` | CSRF protection with Double-Submit Cookie pattern |
207208
| `toon` | LLM-optimized TOON format responses |
208209
| `ws` | WebSocket support with broadcast |
209210
| `view` | Template engine (Tera) for SSR |

crates/rustapi-extras/src/lib.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,33 @@
1010
//! - `jwt` - JWT authentication middleware and `AuthUser<T>` extractor
1111
//! - `cors` - CORS middleware with builder pattern configuration
1212
//! - `rate-limit` - IP-based rate limiting middleware
13+
//! - `csrf` - CSRF protection using Double-Submit Cookie pattern
1314
//! - `config` - Configuration management with `.env` file support
1415
//! - `cookies` - Cookie parsing extractor
1516
//! - `sqlx` - SQLx database error conversion to ApiError
1617
//! - `insight` - Traffic insight middleware for analytics and debugging
1718
//! - `extras` - Meta feature enabling jwt, cors, and rate-limit
1819
//! - `full` - All features enabled
1920
//!
21+
//! ## CSRF Protection Example
22+
//!
23+
//! ```rust,ignore
24+
//! use rustapi_core::RustApi;
25+
//! use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken};
26+
//!
27+
//! let config = CsrfConfig::new()
28+
//! .cookie_name("csrf_token")
29+
//! .header_name("X-CSRF-Token");
30+
//!
31+
//! let app = RustApi::new()
32+
//! .layer(CsrfLayer::new(config));
33+
//! ```
34+
//!
2035
//! ## Example
2136
//!
2237
//! ```toml
2338
//! [dependencies]
24-
//! rustapi-extras = { version = "0.1", features = ["jwt", "cors", "insight"] }
39+
//! rustapi-extras = { version = "0.1", features = ["jwt", "cors", "csrf"] }
2540
//! ```
2641
2742
#![warn(missing_docs)]

docs/cookbook/src/SUMMARY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
- [Part IV: Recipes](recipes/README.md)
2929
- [Creating Resources](recipes/crud_resource.md)
3030
- [JWT Authentication](recipes/jwt_auth.md)
31+
- [CSRF Protection](recipes/csrf_protection.md)
3132
- [Database Integration](recipes/db_integration.md)
3233
- [File Uploads](recipes/file_uploads.md)
3334
- [Custom Middleware](recipes/custom_middleware.md)
3435
- [Real-time Chat](recipes/websockets.md)
3536
- [Production Tuning](recipes/high_performance.md)
3637

38+

docs/cookbook/src/crates/rustapi_extras.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This crate is a collection of production-ready middleware. Everything is behind
1111
|---------|-----------|
1212
| `jwt` | `JwtLayer`, `AuthUser` extractor |
1313
| `cors` | `CorsLayer` |
14+
| `csrf` | `CsrfLayer`, `CsrfToken` extractor |
1415
| `audit` | `AuditStore`, `AuditLogger` |
1516
| `rate-limit` | `RateLimitLayer` |
1617

@@ -25,6 +26,46 @@ let app = RustApi::new()
2526
.route("/", get(handler));
2627
```
2728

29+
## CSRF Protection
30+
31+
Cross-Site Request Forgery protection using the Double-Submit Cookie pattern.
32+
33+
```rust
34+
use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken};
35+
36+
// Configure CSRF middleware
37+
let csrf_config = CsrfConfig::new()
38+
.cookie_name("csrf_token")
39+
.header_name("X-CSRF-Token")
40+
.cookie_secure(true); // HTTPS only
41+
42+
let app = RustApi::new()
43+
.layer(CsrfLayer::new(csrf_config))
44+
.route("/form", get(show_form))
45+
.route("/submit", post(handle_submit));
46+
```
47+
48+
### Extracting the Token
49+
50+
Use the `CsrfToken` extractor to access the token in handlers:
51+
52+
```rust
53+
#[rustapi_rs::get("/form")]
54+
async fn show_form(token: CsrfToken) -> Html<String> {
55+
Html(format!(r#"
56+
<input type="hidden" name="_csrf" value="{}" />
57+
"#, token.as_str()))
58+
}
59+
```
60+
61+
### How It Works
62+
63+
1. **Safe methods** (`GET`, `HEAD`) generate and set the token cookie
64+
2. **Unsafe methods** (`POST`, `PUT`, `DELETE`) require the token in the `X-CSRF-Token` header
65+
3. If header doesn't match cookie → `403 Forbidden`
66+
67+
See [CSRF Protection Recipe](../recipes/csrf_protection.md) for a complete guide.
68+
2869
## Audit Logging
2970

3071
For enterprise compliance (GDPR/SOC2), the `audit` feature provides a structured way to record sensitive actions.
@@ -40,3 +81,4 @@ async fn delete_user(
4081
);
4182
}
4283
```
84+
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# CSRF Protection
2+
3+
Cross-Site Request Forgery (CSRF) protection for your RustAPI applications using the **Double-Submit Cookie** pattern.
4+
5+
## What is CSRF?
6+
7+
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.
8+
9+
RustAPI's CSRF protection works by:
10+
1. Generating a cryptographic token stored in a cookie
11+
2. Requiring the same token in a request header for state-changing requests
12+
3. Rejecting requests where the cookie and header don't match
13+
14+
## Quick Start
15+
16+
```toml
17+
[dependencies]
18+
rustapi-rs = { version = "0.1", features = ["csrf"] }
19+
```
20+
21+
```rust
22+
use rustapi_rs::prelude::*;
23+
use rustapi_extras::csrf::{CsrfConfig, CsrfLayer, CsrfToken};
24+
25+
#[rustapi_rs::get("/form")]
26+
async fn show_form(token: CsrfToken) -> Html<String> {
27+
Html(format!(r#"
28+
<form method="POST" action="/submit">
29+
<input type="hidden" name="csrf_token" value="{}" />
30+
<button type="submit">Submit</button>
31+
</form>
32+
"#, token.as_str()))
33+
}
34+
35+
#[rustapi_rs::post("/submit")]
36+
async fn handle_submit() -> &'static str {
37+
// If we get here, CSRF validation passed!
38+
"Form submitted successfully"
39+
}
40+
41+
#[tokio::main]
42+
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
43+
let csrf_config = CsrfConfig::new()
44+
.cookie_name("csrf_token")
45+
.header_name("X-CSRF-Token");
46+
47+
RustApi::new()
48+
.layer(CsrfLayer::new(csrf_config))
49+
.mount(show_form)
50+
.mount(handle_submit)
51+
.run("127.0.0.1:8080")
52+
.await
53+
}
54+
```
55+
56+
## Configuration Options
57+
58+
```rust
59+
let config = CsrfConfig::new()
60+
// Cookie settings
61+
.cookie_name("csrf_token") // Default: "csrf_token"
62+
.cookie_path("/") // Default: "/"
63+
.cookie_domain("example.com") // Default: None (same domain)
64+
.cookie_secure(true) // Default: true (HTTPS only)
65+
.cookie_http_only(false) // Default: false (JS needs access)
66+
.cookie_same_site(SameSite::Strict) // Default: Strict
67+
68+
// Token settings
69+
.header_name("X-CSRF-Token") // Default: "X-CSRF-Token"
70+
.token_length(32); // Default: 32 bytes
71+
```
72+
73+
## How It Works
74+
75+
### Safe Methods (No Validation)
76+
77+
`GET`, `HEAD`, `OPTIONS`, and `TRACE` requests are considered "safe" and don't modify state. The CSRF middleware:
78+
79+
1. ✅ Generates a new token if none exists
80+
2. ✅ Sets the token cookie in the response
81+
3.**Does NOT validate** the header
82+
83+
### Unsafe Methods (Validation Required)
84+
85+
`POST`, `PUT`, `PATCH`, and `DELETE` requests require CSRF validation:
86+
87+
1. 🔍 Reads the token from the cookie
88+
2. 🔍 Reads the expected token from the header
89+
3. ❌ If missing or mismatched → Returns `403 Forbidden`
90+
4. ✅ If valid → Proceeds to handler
91+
92+
## Frontend Integration
93+
94+
### HTML Forms
95+
96+
For traditional form submissions, include the token as a hidden field:
97+
98+
```html
99+
<form method="POST" action="/api/submit">
100+
<input type="hidden" name="_csrf" value="{{ csrf_token }}" />
101+
<!-- form fields -->
102+
<button type="submit">Submit</button>
103+
</form>
104+
```
105+
106+
### JavaScript / AJAX
107+
108+
For API calls, include the token in the request header:
109+
110+
```javascript
111+
// Read token from cookie
112+
function getCsrfToken() {
113+
return document.cookie
114+
.split('; ')
115+
.find(row => row.startsWith('csrf_token='))
116+
?.split('=')[1];
117+
}
118+
119+
// Include in fetch requests
120+
fetch('/api/users', {
121+
method: 'POST',
122+
headers: {
123+
'Content-Type': 'application/json',
124+
'X-CSRF-Token': getCsrfToken()
125+
},
126+
body: JSON.stringify({ name: 'John' })
127+
});
128+
```
129+
130+
### Axios Interceptor
131+
132+
```javascript
133+
import axios from 'axios';
134+
135+
axios.interceptors.request.use(config => {
136+
if (['post', 'put', 'patch', 'delete'].includes(config.method)) {
137+
config.headers['X-CSRF-Token'] = getCsrfToken();
138+
}
139+
return config;
140+
});
141+
```
142+
143+
## Extracting the Token in Handlers
144+
145+
Use the `CsrfToken` extractor to access the current token in your handlers:
146+
147+
```rust
148+
use rustapi_extras::csrf::CsrfToken;
149+
150+
#[rustapi_rs::get("/api/csrf-token")]
151+
async fn get_csrf_token(token: CsrfToken) -> Json<serde_json::Value> {
152+
Json(serde_json::json!({
153+
"csrf_token": token.as_str()
154+
}))
155+
}
156+
```
157+
158+
## Best Practices
159+
160+
### 1. Always Use HTTPS in Production
161+
162+
```rust
163+
let config = CsrfConfig::new()
164+
.cookie_secure(true); // Cookie only sent over HTTPS
165+
```
166+
167+
### 2. Use Strict SameSite Policy
168+
169+
```rust
170+
use cookie::SameSite;
171+
172+
let config = CsrfConfig::new()
173+
.cookie_same_site(SameSite::Strict); // Most restrictive
174+
```
175+
176+
### 3. Combine with Other Security Measures
177+
178+
```rust
179+
RustApi::new()
180+
.layer(CsrfLayer::new(csrf_config))
181+
.layer(SecurityHeadersLayer::strict()) // Add security headers
182+
.layer(CorsLayer::permissive()) // Configure CORS
183+
```
184+
185+
### 4. Rotate Tokens Periodically
186+
187+
Consider regenerating tokens after sensitive actions:
188+
189+
```rust
190+
#[rustapi_rs::post("/auth/login")]
191+
async fn login(/* ... */) -> impl IntoResponse {
192+
// After successful login, a new CSRF token will be
193+
// generated on the next GET request
194+
// ...
195+
}
196+
```
197+
198+
## Testing CSRF Protection
199+
200+
```rust
201+
use rustapi_testing::{TestClient, TestRequest};
202+
203+
#[tokio::test]
204+
async fn test_csrf_protection() {
205+
let app = create_app_with_csrf();
206+
let client = TestClient::new(app);
207+
208+
// GET request should work and set cookie
209+
let res = client.get("/form").await;
210+
assert_eq!(res.status(), StatusCode::OK);
211+
212+
let csrf_cookie = res.headers()
213+
.get("set-cookie")
214+
.unwrap()
215+
.to_str()
216+
.unwrap();
217+
218+
// Extract token value
219+
let token = csrf_cookie
220+
.split(';')
221+
.next()
222+
.unwrap()
223+
.split('=')
224+
.nth(1)
225+
.unwrap();
226+
227+
// POST without token should fail
228+
let res = client.post("/submit").await;
229+
assert_eq!(res.status(), StatusCode::FORBIDDEN);
230+
231+
// POST with correct token should succeed
232+
let res = client.request(
233+
TestRequest::post("/submit")
234+
.header("Cookie", format!("csrf_token={}", token))
235+
.header("X-CSRF-Token", token)
236+
).await;
237+
assert_eq!(res.status(), StatusCode::OK);
238+
}
239+
```
240+
241+
## Error Handling
242+
243+
When CSRF validation fails, the middleware returns a JSON error response:
244+
245+
```json
246+
{
247+
"error": {
248+
"code": "csrf_forbidden",
249+
"message": "CSRF token validation failed"
250+
}
251+
}
252+
```
253+
254+
You can customize this by wrapping the layer with your own error handler.
255+
256+
## Security Considerations
257+
258+
| Consideration | Status |
259+
|--------------|--------|
260+
| Token in cookie | ✅ HttpOnly=false (JS needs access) |
261+
| Token validation | ✅ Constant-time comparison |
262+
| SameSite cookie | ✅ Configurable (Strict by default) |
263+
| Secure cookie | ✅ HTTPS-only by default |
264+
| Token entropy | ✅ 32 bytes of cryptographic randomness |
265+
266+
## See Also
267+
268+
- [JWT Authentication](jwt_auth.md) - Token-based authentication
269+
- [Security Headers](../crates/rustapi_extras.md#security-headers) - Additional security layers
270+
- [CORS Configuration](../crates/rustapi_extras.md#cors) - Cross-origin request handling

0 commit comments

Comments
 (0)