Skip to main content

max / makenotwork

9.0 KB · 262 lines History Blame Raw
1 //! CSRF (Cross-Site Request Forgery) protection.
2 //!
3 //! Synchronizer token pattern: generate random token on session start,
4 //! include in meta tag, validate on state-changing requests via X-CSRF-Token header.
5
6 use axum::{
7 extract::Request,
8 http::StatusCode,
9 middleware::Next,
10 response::{IntoResponse, Response},
11 };
12 use rand::Rng;
13 use tower_sessions::Session;
14
15 const CSRF_SESSION_KEY: &str = "csrf_token";
16 const CSRF_TOKEN_LENGTH: usize = 32;
17
18 /// Generate a new random CSRF token (64-char hex string).
19 pub fn generate_token() -> String {
20 let mut token = [0u8; CSRF_TOKEN_LENGTH];
21 rand::rng().fill_bytes(&mut token);
22 hex::encode(token)
23 }
24
25 /// Get the existing CSRF token from the session, or create and store a new one.
26 ///
27 /// Returns a 500 response if the new token could not be written to the session
28 /// store. Rendering a page around a token the session does not hold produces a
29 /// form whose next POST fails validation for no reason the user can see, which
30 /// is worse than an error: the middleware compares against the stored token, so
31 /// an unstored token is a guaranteed silent failure later. Failing here keeps
32 /// the failure where it happened.
33 pub async fn get_or_create_token(session: &Session) -> Result<String, Response> {
34 if let Ok(Some(token)) = session.get::<String>(CSRF_SESSION_KEY).await {
35 return Ok(token);
36 }
37 let token = generate_token();
38 match session.insert(CSRF_SESSION_KEY, &token).await {
39 Ok(()) => Ok(token),
40 Err(e) => {
41 tracing::error!(error = ?e, "failed to store CSRF token in session");
42 Err((
43 StatusCode::INTERNAL_SERVER_ERROR,
44 "Session store unavailable",
45 )
46 .into_response())
47 }
48 }
49 }
50
51 /// Constant-time comparison to prevent timing attacks.
52 pub fn constant_time_compare(a: &str, b: &str) -> bool {
53 if a.len() != b.len() {
54 return false;
55 }
56 a.bytes()
57 .zip(b.bytes())
58 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
59 == 0
60 }
61
62 /// Extract a single field value from an `application/x-www-form-urlencoded`
63 /// body, decoding `+` and `%XX` escapes. Returns the first match. Kept
64 /// dependency-free; only used on the no-JS CSRF fallback path.
65 fn extract_form_field(bytes: &[u8], key: &str) -> Option<String> {
66 for pair in bytes.split(|&b| b == b'&') {
67 let mut it = pair.splitn(2, |&b| b == b'=');
68 let k = it.next().unwrap_or(b"");
69 if k == key.as_bytes() {
70 return Some(percent_decode_form(it.next().unwrap_or(b"")));
71 }
72 }
73 None
74 }
75
76 /// Decode a urlencoded form value: `+` → space, `%XX` → byte, lossy UTF-8.
77 fn percent_decode_form(input: &[u8]) -> String {
78 let mut out = Vec::with_capacity(input.len());
79 let mut i = 0;
80 while i < input.len() {
81 match input[i] {
82 b'+' => out.push(b' '),
83 b'%' if i + 2 < input.len() => {
84 let hi = (input[i + 1] as char).to_digit(16);
85 let lo = (input[i + 2] as char).to_digit(16);
86 if let (Some(hi), Some(lo)) = (hi, lo) {
87 out.push((hi * 16 + lo) as u8);
88 i += 3;
89 continue;
90 }
91 out.push(b'%');
92 }
93 b => out.push(b),
94 }
95 i += 1;
96 }
97 String::from_utf8_lossy(&out).into_owned()
98 }
99
100 /// Max body size we'll buffer to recover a CSRF token from a form field on the
101 /// no-JS fallback path. Forms are tiny; anything larger isn't a urlencoded form
102 /// we'd be parsing a token out of.
103 const MAX_FORM_FALLBACK_BYTES: usize = 64 * 1024;
104
105 /// Middleware: validate the CSRF token on POST/PUT/PATCH/DELETE.
106 ///
107 /// Two delivery paths, in order:
108 /// 1. `X-CSRF-Token` header (set by mt.js for every fetch/HTMX request). This
109 /// is the fast path, the request body is never touched.
110 /// 2. A hidden `csrf_token` form field, for graceful degradation when mt.js
111 /// didn't run (JS disabled, asset failure). Only urlencoded bodies are
112 /// inspected, and only when the header is absent; multipart uploads remain
113 /// header-only. The body is buffered, validated, and re-attached so the
114 /// downstream handler still sees it.
115 ///
116 /// `/auth/` is deliberately NOT exempt: its only mutating routes (`logout`,
117 /// `refresh`) are same-origin forms that carry the token, so they get CSRF
118 /// protection like everything else. `login`/`callback` are GET and so never
119 /// reach this check. `/_test/` is only ever mounted by the integration harness
120 /// (never in production); `/api/health` is GET-only.
121 pub async fn csrf_middleware(request: Request, next: Next) -> Response {
122 let method = request.method().clone();
123
124 if !["POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
125 return next.run(request).await;
126 }
127
128 let path = request.uri().path().to_string();
129
130 let exempt_prefixes = ["/api/health", "/_test/"];
131 if exempt_prefixes.iter().any(|p| path.starts_with(p)) {
132 return next.run(request).await;
133 }
134
135 let session = match request.extensions().get::<Session>() {
136 Some(s) => s.clone(),
137 None => {
138 tracing::warn!("CSRF check failed: no session");
139 return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
140 }
141 };
142
143 let session_token: Option<String> = session.get(CSRF_SESSION_KEY).await.ok().flatten();
144
145 // Fast path: token in the header (mt.js). Body untouched.
146 if let Some(header_token) = request
147 .headers()
148 .get("X-CSRF-Token")
149 .and_then(|v| v.to_str().ok())
150 .map(std::string::ToString::to_string)
151 {
152 return match session_token {
153 Some(ref expected) if constant_time_compare(expected, &header_token) => {
154 next.run(request).await
155 }
156 _ => {
157 tracing::warn!(path = %path, "CSRF token mismatch");
158 (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
159 }
160 };
161 }
162
163 // Fallback path: no header. Accept a hidden `csrf_token` form field from a
164 // urlencoded body so the site degrades gracefully without JS.
165 let is_form = request
166 .headers()
167 .get(axum::http::header::CONTENT_TYPE)
168 .and_then(|v| v.to_str().ok())
169 .is_some_and(|ct| ct.starts_with("application/x-www-form-urlencoded"));
170
171 if !is_form {
172 tracing::warn!(path = %path, "CSRF token missing");
173 return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
174 }
175
176 let (parts, body) = request.into_parts();
177 let Ok(bytes) = axum::body::to_bytes(body, MAX_FORM_FALLBACK_BYTES).await else {
178 tracing::warn!(path = %path, "CSRF fallback: body too large or unreadable");
179 return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
180 };
181
182 let form_token = extract_form_field(&bytes, "csrf_token");
183
184 let valid = matches!(
185 (&session_token, &form_token),
186 (Some(expected), Some(provided)) if constant_time_compare(expected, provided)
187 );
188
189 if !valid {
190 tracing::warn!(path = %path, "CSRF token mismatch (form fallback)");
191 return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
192 }
193
194 next.run(Request::from_parts(parts, axum::body::Body::from(bytes)))
195 .await
196 }
197
198 #[cfg(test)]
199 mod tests {
200 use super::*;
201
202 #[test]
203 fn token_length_and_hex() {
204 let token = generate_token();
205 assert_eq!(token.len(), 64);
206 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
207 }
208
209 #[test]
210 fn tokens_are_unique() {
211 let a = generate_token();
212 let b = generate_token();
213 assert_ne!(a, b);
214 }
215
216 #[test]
217 fn constant_time_compare_works() {
218 assert!(constant_time_compare("abc", "abc"));
219 assert!(!constant_time_compare("abc", "abd"));
220 assert!(!constant_time_compare("abc", "abcd"));
221 assert!(!constant_time_compare("", "a"));
222 assert!(constant_time_compare("", ""));
223 }
224
225 #[test]
226 fn extract_form_field_finds_token() {
227 let body = b"username=alice&csrf_token=deadbeef&duration=1h";
228 assert_eq!(
229 extract_form_field(body, "csrf_token").as_deref(),
230 Some("deadbeef")
231 );
232 assert_eq!(
233 extract_form_field(body, "username").as_deref(),
234 Some("alice")
235 );
236 assert_eq!(extract_form_field(body, "missing"), None);
237 }
238
239 #[test]
240 fn extract_form_field_decodes_escapes() {
241 let body = b"reason=a+b%2Fc&csrf_token=abc123";
242 assert_eq!(extract_form_field(body, "reason").as_deref(), Some("a b/c"));
243 assert_eq!(
244 extract_form_field(body, "csrf_token").as_deref(),
245 Some("abc123")
246 );
247 }
248
249 #[test]
250 fn extract_form_field_handles_empty_and_valueless() {
251 assert_eq!(extract_form_field(b"", "csrf_token"), None);
252 assert_eq!(
253 extract_form_field(b"csrf_token=", "csrf_token").as_deref(),
254 Some("")
255 );
256 assert_eq!(
257 extract_form_field(b"csrf_token", "csrf_token").as_deref(),
258 Some("")
259 );
260 }
261 }
262