Skip to main content

max / makenotwork

26.0 KB · 731 lines History Blame Raw
1 //! CSRF (Cross-Site Request Forgery) protection
2 //!
3 //! Uses the synchronizer token pattern:
4 //! 1. Generate random token on session start
5 //! 2. Include token in forms/meta tag
6 //! 3. Validate token on state-changing requests
7
8 use axum::{
9 extract::{FromRequestParts, Request},
10 handler::Handler,
11 http::{header::HeaderMap, request::Parts, StatusCode},
12 middleware::{from_fn, Next},
13 response::{IntoResponse, Response},
14 routing::{delete, patch, post, put, MethodRouter},
15 Router,
16 };
17 use rand::RngCore;
18 use tower_sessions::Session;
19
20 use crate::error::{AppError, ResultExt};
21
22 /// Session key for storing CSRF token
23 pub const CSRF_SESSION_KEY: &str = "csrf_token";
24
25 /// CSRF token length in bytes (32 bytes = 256 bits)
26 const CSRF_TOKEN_LENGTH: usize = 32;
27
28 /// Generate a new CSRF token
29 pub fn generate_token() -> String {
30 let mut token = [0u8; CSRF_TOKEN_LENGTH];
31 rand::rng().fill_bytes(&mut token);
32 hex::encode(token)
33 }
34
35 /// Get or create a CSRF token for the session.
36 ///
37 /// `tower-sessions`' `insert` is last-write-wins, so two concurrent first
38 /// requests (e.g. the user opens two tabs while not yet having a token)
39 /// can each generate a fresh token and clobber each other — the first
40 /// form to post then fails with a 403 because its rendered token has
41 /// already been overwritten.
42 ///
43 /// Re-check via `get` after insert: if a different token landed between
44 /// our get and our insert, prefer THAT value over ours so all renders
45 /// from this point forward agree. `String` is `Clone`-cheap, and the
46 /// race window is small enough that the duplicate insert is negligible.
47 pub async fn get_or_create_token(session: &Session) -> Result<String, AppError> {
48 if let Some(token) = session
49 .get::<String>(CSRF_SESSION_KEY)
50 .await
51 .context("session error")?
52 {
53 return Ok(token);
54 }
55
56 let candidate = generate_token();
57 session
58 .insert(CSRF_SESSION_KEY, &candidate)
59 .await
60 .context("session insert")?;
61
62 // Re-fetch so a concurrent insert wins consistently — whichever caller
63 // wrote last is what every subsequent render will see, and we hand
64 // back the same value here.
65 let final_token: String = session
66 .get(CSRF_SESSION_KEY)
67 .await
68 .context("session error")?
69 .unwrap_or(candidate);
70
71 Ok(final_token)
72 }
73
74 /// Validate a CSRF token against the session token
75 pub async fn validate_token(session: &Session, provided_token: &str) -> Result<bool, AppError> {
76 let session_token: Option<String> = session
77 .get(CSRF_SESSION_KEY)
78 .await
79 .context("session error")?;
80
81 match session_token {
82 Some(token) => Ok(crate::helpers::constant_time_compare(&token, provided_token)),
83 None => Ok(false),
84 }
85 }
86
87 /// Extract CSRF token from request (header or form field)
88 pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Option<String> {
89 // First, try the X-CSRF-Token header (used by HTMX)
90 if let Some(token) = headers
91 .get("X-CSRF-Token")
92 .and_then(|v| v.to_str().ok())
93 .map(|s| s.to_string())
94 {
95 return Some(token);
96 }
97
98 // Fall back to the `_csrf` field in form-encoded body (vanilla HTML
99 // forms). We use a proper urlencoded parser instead of `split('&')`
100 // so a textarea containing `&_csrf=attacker-token` can't sneak past
101 // a later field with the wrong value — the parser respects field
102 // ordering and won't conflate textarea content with form fields
103 // because the form encoder percent-encodes `&` inside text values.
104 if let Some(body_str) = body {
105 for (key, value) in url::form_urlencoded::parse(body_str.as_bytes()) {
106 if key == "_csrf" {
107 return Some(value.into_owned());
108 }
109 }
110 }
111
112 None
113 }
114
115 /// Extractor for CSRF token from session
116 pub struct CsrfToken(pub String);
117
118 impl<S> FromRequestParts<S> for CsrfToken
119 where
120 S: Send + Sync,
121 {
122 type Rejection = AppError;
123
124 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
125 let session = parts
126 .extensions
127 .get::<Session>()
128 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
129
130 let token = get_or_create_token(session).await?;
131 Ok(CsrfToken(token))
132 }
133 }
134
135 /// Per-route CSRF posture, declared at the route registration site via the
136 /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures
137 /// so the choice (and its reason) lives next to the route, not in a sibling
138 /// allowlist file. Not stored at runtime — the reason strings exist for
139 /// source-level documentation and grep, while the structural guarantee comes
140 /// from `CsrfRouter` only accepting `PostureMethodRouter` values.
141 #[derive(Clone, Copy, Debug)]
142 pub enum CsrfPosture {
143 /// Standard validation layer runs (header or form `_csrf`).
144 Auto,
145 /// Handler validates the token itself and proves it with the
146 /// `CsrfManuallyValidated` witness. Reason documents why the
147 /// standard layer can't apply (e.g. "multipart upload").
148 Manual(&'static str),
149 /// No CSRF check applies. Reason documents why (webhook signature,
150 /// signed link, pre-auth, etc.).
151 Skip(&'static str),
152 }
153
154 /// Witness type proving a handler ran the standard CSRF validation path.
155 /// The only public way to obtain one is `validate_token_consuming`, which
156 /// performs the check. The private field with a private-module constructor
157 /// makes the value un-fabricable from outside this module — `Default`,
158 /// struct-literal, and `Clone` are all impossible for callers.
159 pub use sealed::CsrfManuallyValidated;
160
161 mod sealed {
162 pub struct CsrfManuallyValidated {
163 _private: (),
164 }
165
166 pub(super) fn make_validated() -> CsrfManuallyValidated {
167 CsrfManuallyValidated { _private: () }
168 }
169 }
170
171 /// Validate a token and return a sealed witness on success. Used by
172 /// handlers registered with `post_csrf_manual` (and method variants)
173 /// that need to validate inside the handler body — typically because the
174 /// global middleware can't read the token for this content type (e.g.
175 /// multipart) or because validation is conditional on request state.
176 pub async fn validate_token_consuming(
177 session: &Session,
178 provided_token: &str,
179 ) -> Result<CsrfManuallyValidated, AppError> {
180 if validate_token(session, provided_token).await? {
181 Ok(sealed::make_validated())
182 } else {
183 Err(AppError::Forbidden)
184 }
185 }
186
187 // Manual-posture runtime assertion (dev/test only): attempted via a tokio
188 // task-local flag set in `validate_token_consuming` and checked in a per-
189 // route layer. Backed out 2026-05-27 — false-positive density was too high:
190 // rendered error pages return 200, rate-limit and form-extraction
191 // short-circuit before the handler, and the audit explicitly marked this
192 // follow-up as "not blocking — only matters if Manual grows beyond one
193 // route". Compile-time discipline (the `CsrfManuallyValidated` witness type
194 // bound as `_validated`) stays the convention.
195
196 /// Wrap a method-router with the Auto-posture validation layer.
197 /// Runs `validate_auto` on every request that reaches the route.
198 fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S>
199 where
200 S: Clone + Send + Sync + 'static,
201 {
202 method_router.layer(from_fn(|req: Request, next: Next| async move {
203 let path = req.uri().path().to_string();
204 validate_auto(req, next, &path).await
205 }))
206 }
207
208 /// A `MethodRouter` that has been through one of the CSRF helpers. Field
209 /// is private and constructible only inside this module, so
210 /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`
211 /// — route files have to use the helpers, by construction.
212 pub use posture_router::PostureMethodRouter;
213
214 mod posture_router {
215 use super::*;
216
217 pub struct PostureMethodRouter<S = ()>(pub(super) MethodRouter<S>);
218
219 impl<S> PostureMethodRouter<S>
220 where
221 S: Clone + Send + Sync + 'static,
222 {
223 pub(super) fn new(inner: MethodRouter<S>) -> Self {
224 Self(inner)
225 }
226
227 pub(super) fn into_inner(self) -> MethodRouter<S> {
228 self.0
229 }
230
231 /// Attach an additional tower layer (e.g. a rate limiter) to the
232 /// underlying method router. Returns `Self` so callers don't lose
233 /// the posture stamp.
234 pub fn layer<L>(self, layer: L) -> Self
235 where
236 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
237 L::Service:
238 tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
239 <L::Service as tower::Service<axum::extract::Request>>::Response:
240 axum::response::IntoResponse + 'static,
241 <L::Service as tower::Service<axum::extract::Request>>::Error:
242 Into<std::convert::Infallible> + 'static,
243 <L::Service as tower::Service<axum::extract::Request>>::Future:
244 Send + 'static,
245 {
246 Self(self.0.layer(layer))
247 }
248 }
249 }
250
251 macro_rules! csrf_auto_helper {
252 ($name:ident, $axum_fn:ident) => {
253 pub fn $name<H, T, S>(handler: H) -> PostureMethodRouter<S>
254 where
255 H: Handler<T, S>,
256 T: 'static,
257 S: Clone + Send + Sync + 'static,
258 {
259 posture_router::PostureMethodRouter::new(attach_auto_layer($axum_fn(handler)))
260 }
261 };
262 }
263
264 macro_rules! csrf_passthrough_helper {
265 ($name:ident, $axum_fn:ident, $variant:ident) => {
266 pub fn $name<H, T, S>(reason: &'static str, handler: H) -> PostureMethodRouter<S>
267 where
268 H: Handler<T, S>,
269 T: 'static,
270 S: Clone + Send + Sync + 'static,
271 {
272 let _ = CsrfPosture::$variant(reason);
273 posture_router::PostureMethodRouter::new($axum_fn(handler))
274 }
275 };
276 }
277
278 // Auto posture: standard CSRF validation (header or form `_csrf`).
279 csrf_auto_helper!(post_csrf, post);
280 csrf_auto_helper!(put_csrf, put);
281 csrf_auto_helper!(patch_csrf, patch);
282 csrf_auto_helper!(delete_csrf, delete);
283
284 // Manual posture: handler validates via `validate_token_consuming`.
285 csrf_passthrough_helper!(post_csrf_manual, post, Manual);
286 csrf_passthrough_helper!(put_csrf_manual, put, Manual);
287 csrf_passthrough_helper!(patch_csrf_manual, patch, Manual);
288 csrf_passthrough_helper!(delete_csrf_manual, delete, Manual);
289
290 // Skip posture: no CSRF check. Reason documents why.
291 csrf_passthrough_helper!(post_csrf_skip, post, Skip);
292 csrf_passthrough_helper!(put_csrf_skip, put, Skip);
293 csrf_passthrough_helper!(patch_csrf_skip, patch, Skip);
294 csrf_passthrough_helper!(delete_csrf_skip, delete, Skip);
295
296 // --- Wrappers for multi-method routes ------------------------------------
297 //
298 // A handful of routes register multiple HTTP methods on one path
299 // (e.g. `get(list).post(create)`). The handler-taking helpers above can't
300 // compose with these because the chain is already a `MethodRouter`. These
301 // wrappers take a pre-built `MethodRouter` and stamp it as a
302 // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected — the
303 // Auto validation layer only intercepts state-changing methods at the
304 // per-route level because that's what the helper attached to.
305
306 /// Wrap a multi-method chain with the Auto-posture validation layer.
307 pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S>
308 where
309 S: Clone + Send + Sync + 'static,
310 {
311 posture_router::PostureMethodRouter::new(attach_auto_layer(method_router))
312 }
313
314 /// Stamp a multi-method chain as Manual — handler is responsible for
315 /// calling `validate_token_consuming`.
316 pub fn with_csrf_manual<S>(
317 reason: &'static str,
318 method_router: MethodRouter<S>,
319 ) -> PostureMethodRouter<S>
320 where
321 S: Clone + Send + Sync + 'static,
322 {
323 let _ = CsrfPosture::Manual(reason);
324 posture_router::PostureMethodRouter::new(method_router)
325 }
326
327 /// Stamp a multi-method chain as Skip — no CSRF check applies.
328 pub fn with_csrf_skip<S>(
329 reason: &'static str,
330 method_router: MethodRouter<S>,
331 ) -> PostureMethodRouter<S>
332 where
333 S: Clone + Send + Sync + 'static,
334 {
335 let _ = CsrfPosture::Skip(reason);
336 posture_router::PostureMethodRouter::new(method_router)
337 }
338
339 // --- CsrfRouter: structural enforcement ----------------------------------
340 //
341 // `CsrfRouter` is the only way to register a mutation route in this
342 // codebase. Its `route` method takes a `PostureMethodRouter<S>`, whose
343 // constructor is private to this module, so the only producers are the
344 // helpers above. A bare `Router::route(path, post(handler))` cannot
345 // reach a mounted `CsrfRouter` without going through `finalize()` first,
346 // which is only called once in `build_app`.
347
348 pub struct CsrfRouter<S = ()>(Router<S>);
349
350 impl<S> Default for CsrfRouter<S>
351 where
352 S: Clone + Send + Sync + 'static,
353 {
354 fn default() -> Self {
355 Self::new()
356 }
357 }
358
359 impl<S> CsrfRouter<S>
360 where
361 S: Clone + Send + Sync + 'static,
362 {
363 pub fn new() -> Self {
364 Self(Router::new())
365 }
366
367 pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
368 Self(self.0.route(path, posture.into_inner()))
369 }
370
371 /// Register a read-only route (GET / HEAD / OPTIONS). The structural
372 /// guarantee only constrains state-changing methods, so read-only
373 /// `MethodRouter`s pass through unchanged. Calling this with a
374 /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but
375 /// readers can see the intent at the call site — and any mutation
376 /// route registered through `route_get` is a bug visible in review.
377 pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self {
378 Self(self.0.route(path, method_router))
379 }
380
381 pub fn merge(self, other: Self) -> Self {
382 Self(self.0.merge(other.0))
383 }
384
385 pub fn nest(self, path: &str, other: Self) -> Self {
386 Self(self.0.nest(path, other.0))
387 }
388
389 pub fn layer<L>(self, layer: L) -> Self
390 where
391 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
392 L::Service:
393 tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
394 <L::Service as tower::Service<axum::extract::Request>>::Response:
395 IntoResponse + 'static,
396 <L::Service as tower::Service<axum::extract::Request>>::Error:
397 Into<std::convert::Infallible> + 'static,
398 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
399 {
400 Self(self.0.layer(layer))
401 }
402
403 pub fn route_layer<L>(self, layer: L) -> Self
404 where
405 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
406 L::Service:
407 tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
408 <L::Service as tower::Service<axum::extract::Request>>::Response:
409 IntoResponse + 'static,
410 <L::Service as tower::Service<axum::extract::Request>>::Error:
411 Into<std::convert::Infallible> + 'static,
412 <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
413 {
414 Self(self.0.route_layer(layer))
415 }
416
417 /// Drop the structural envelope and return the underlying `Router<S>`.
418 /// Called once in `build_app` after all mutation routes have been
419 /// registered; downstream code may then attach global layers, mount
420 /// static-file services, and add GET-only routes.
421 pub fn finalize(self) -> Router<S> {
422 self.0
423 }
424 }
425
426 /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body
427 /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
428 /// and by the path-allowlist fallback during the L2 migration.
429 async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
430 // Safe methods (RFC 9110 §9.2.1) are read-only by definition — never
431 // CSRF-check them. This matters for multi-method routes wrapped by
432 // `with_csrf(get(load).post(save))`: a bare GET should not require a
433 // token (and the harness doesn't send one for GETs).
434 if !matches!(*request.method(), axum::http::Method::POST
435 | axum::http::Method::PUT
436 | axum::http::Method::PATCH
437 | axum::http::Method::DELETE)
438 {
439 return next.run(request).await;
440 }
441
442 // Get session from extensions
443 let session = match request.extensions().get::<Session>() {
444 Some(s) => s.clone(),
445 None => {
446 tracing::warn!("CSRF check failed: no session");
447 return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
448 }
449 };
450
451 // Try header first (HTMX requests)
452 let header_token = request
453 .headers()
454 .get("X-CSRF-Token")
455 .and_then(|v| v.to_str().ok())
456 .map(|s| s.to_string());
457
458 if let Some(ref token) = header_token {
459 return match validate_token(&session, token).await {
460 Ok(true) => next.run(request).await,
461 Ok(false) => {
462 tracing::warn!(path = %path, "CSRF token mismatch");
463 crate::error::AppError::Forbidden.into_response()
464 }
465 Err(e) => {
466 tracing::error!(error = ?e, "CSRF validation error");
467 crate::error::AppError::Internal(anyhow::anyhow!("CSRF validation error")).into_response()
468 }
469 };
470 }
471
472 // No header token — check if the user is authenticated
473 let has_user: bool = session
474 .get::<crate::auth::SessionUser>("user")
475 .await
476 .ok()
477 .flatten()
478 .is_some();
479
480 if !has_user {
481 return next.run(request).await;
482 }
483
484 // Authenticated user without header token — check form body for `_csrf`.
485 // We only parse `application/x-www-form-urlencoded`. Other content
486 // types are rejected here:
487 // - `multipart/form-data` is the closest near-miss: it has its own
488 // `_csrf` part but parsing it would mean pulling in a multipart
489 // decoder and buffering the entire upload body, defeating the
490 // upload-size limit. The codebase doesn't currently use multipart
491 // forms (uploads go through HTMX + fetch, which attach
492 // `X-CSRF-Token` on the header path above), so rejecting here is
493 // the explicit boundary. If multipart adoption ever becomes
494 // necessary, register the route with `post_csrf_manual` and have
495 // the handler stream the body through a multipart parser before
496 // calling `validate_token_consuming`.
497 // - `application/json` and others must use the `X-CSRF-Token`
498 // header — anything that can set a custom header can set this one.
499 let content_type = request
500 .headers()
501 .get("content-type")
502 .and_then(|v| v.to_str().ok())
503 .unwrap_or("");
504 let is_form = content_type.starts_with("application/x-www-form-urlencoded");
505
506 if !is_form {
507 let is_multipart = content_type.starts_with("multipart/form-data");
508 tracing::warn!(
509 path = %path,
510 content_type,
511 is_multipart,
512 "CSRF token missing for authenticated non-form request"
513 );
514 return crate::error::AppError::Forbidden.into_response();
515 }
516
517 // Buffer the body to extract _csrf, then reconstruct the request.
518 // Limit matches the global RequestBodyLimitLayer (1 MB) so that any
519 // form body accepted by the server can have its CSRF token extracted.
520 let (parts, body) = request.into_parts();
521 let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
522 Ok(b) => b,
523 Err(_) => {
524 return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
525 }
526 };
527
528 let body_str = String::from_utf8_lossy(&bytes);
529 let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str));
530
531 let token = match body_token {
532 Some(t) => t,
533 None => {
534 tracing::warn!(path = %path, "CSRF token missing from form body");
535 return crate::error::AppError::Forbidden.into_response();
536 }
537 };
538
539 match validate_token(&session, &token).await {
540 Ok(true) => {
541 // Reconstruct request with the buffered body
542 let request = Request::from_parts(parts, axum::body::Body::from(bytes));
543 next.run(request).await
544 }
545 Ok(false) => {
546 tracing::warn!(path = %path, "CSRF token mismatch");
547 (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
548 }
549 Err(e) => {
550 tracing::error!(error = ?e, "CSRF validation error");
551 (StatusCode::INTERNAL_SERVER_ERROR, "CSRF validation error").into_response()
552 }
553 }
554 }
555
556 #[cfg(test)]
557 mod tests {
558 use super::*;
559
560 #[test]
561 fn test_generate_token() {
562 let token1 = generate_token();
563 let token2 = generate_token();
564
565 // Tokens should be 64 hex characters (32 bytes)
566 assert_eq!(token1.len(), 64);
567 assert_eq!(token2.len(), 64);
568
569 // Tokens should be different
570 assert_ne!(token1, token2);
571 }
572
573 #[test]
574 fn test_constant_time_compare() {
575 use crate::helpers::constant_time_compare;
576 assert!(constant_time_compare("abc", "abc"));
577 assert!(!constant_time_compare("abc", "abd"));
578 assert!(!constant_time_compare("abc", "abcd"));
579 assert!(!constant_time_compare("", "a"));
580 }
581
582 #[test]
583 fn test_generate_token_is_hex() {
584 let token = generate_token();
585 // Should be valid hex
586 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
587 }
588
589 #[test]
590 fn test_extract_token_from_header() {
591 let mut headers = HeaderMap::new();
592 headers.insert("X-CSRF-Token", "abc123".parse().unwrap());
593 let token = extract_token_from_request(&headers, None);
594 assert_eq!(token.as_deref(), Some("abc123"));
595 }
596
597 #[test]
598 fn test_extract_token_from_form_body() {
599 let headers = HeaderMap::new();
600 let body = "name=value&_csrf=mytoken123&other=data";
601 let token = extract_token_from_request(&headers, Some(body));
602 assert_eq!(token.as_deref(), Some("mytoken123"));
603 }
604
605 #[test]
606 fn test_extract_token_missing() {
607 let headers = HeaderMap::new();
608 let token = extract_token_from_request(&headers, None);
609 assert!(token.is_none());
610 }
611
612 #[test]
613 fn test_generate_token_unique_across_many() {
614 let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect();
615 let unique: std::collections::HashSet<&String> = tokens.iter().collect();
616 assert_eq!(unique.len(), 100, "all 100 tokens should be unique");
617 }
618
619 #[test]
620 fn test_generate_token_correct_byte_length() {
621 let token = generate_token();
622 let bytes = hex::decode(&token).expect("token should be valid hex");
623 assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH);
624 }
625
626 #[test]
627 fn test_extract_token_header_takes_priority_over_body() {
628 let mut headers = HeaderMap::new();
629 headers.insert("X-CSRF-Token", "header_token".parse().unwrap());
630 let body = "_csrf=body_token";
631 let token = extract_token_from_request(&headers, Some(body));
632 assert_eq!(token.as_deref(), Some("header_token"));
633 }
634
635 #[test]
636 fn test_extract_token_from_body_url_encoded() {
637 let headers = HeaderMap::new();
638 let body = "_csrf=token%20with%20spaces&other=val";
639 let token = extract_token_from_request(&headers, Some(body));
640 assert_eq!(token.as_deref(), Some("token with spaces"));
641 }
642
643 #[test]
644 fn test_extract_token_csrf_at_start_of_body() {
645 let headers = HeaderMap::new();
646 let body = "_csrf=firstfield&name=value";
647 let token = extract_token_from_request(&headers, Some(body));
648 assert_eq!(token.as_deref(), Some("firstfield"));
649 }
650
651 #[test]
652 fn test_extract_token_csrf_at_end_of_body() {
653 let headers = HeaderMap::new();
654 let body = "name=value&_csrf=lastfield";
655 let token = extract_token_from_request(&headers, Some(body));
656 assert_eq!(token.as_deref(), Some("lastfield"));
657 }
658
659 #[test]
660 fn test_extract_token_empty_body() {
661 let headers = HeaderMap::new();
662 let token = extract_token_from_request(&headers, Some(""));
663 assert!(token.is_none());
664 }
665
666 #[test]
667 fn test_extract_token_body_without_csrf_field() {
668 let headers = HeaderMap::new();
669 let body = "name=value&other=data";
670 let token = extract_token_from_request(&headers, Some(body));
671 assert!(token.is_none());
672 }
673
674 #[test]
675 fn test_extract_token_csrf_prefix_mismatch() {
676 let headers = HeaderMap::new();
677 // Field named "_csrfx" should NOT match "_csrf="
678 let body = "_csrfx=notreal";
679 let token = extract_token_from_request(&headers, Some(body));
680 assert!(token.is_none());
681 }
682
683 #[test]
684 fn test_extract_token_empty_csrf_value() {
685 let headers = HeaderMap::new();
686 let body = "_csrf=&other=val";
687 let token = extract_token_from_request(&headers, Some(body));
688 assert_eq!(token.as_deref(), Some(""));
689 }
690
691 #[test]
692 fn test_constant_time_compare_empty_strings() {
693 use crate::helpers::constant_time_compare;
694 assert!(constant_time_compare("", ""));
695 }
696
697 #[test]
698 fn test_constant_time_compare_near_miss() {
699 use crate::helpers::constant_time_compare;
700 let token = generate_token();
701 // Flip last character
702 let mut tampered = token.clone();
703 let last = tampered.pop().unwrap();
704 tampered.push(if last == '0' { '1' } else { '0' });
705 assert!(!constant_time_compare(&token, &tampered));
706 }
707
708 #[test]
709 fn csrf_manually_validated_marker_is_zero_sized() {
710 assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0);
711 }
712
713 #[test]
714 fn csrf_posture_is_copyable_and_carries_reason() {
715 let p = CsrfPosture::Skip("webhook: stripe signature");
716 let copy = p;
717 match copy {
718 CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"),
719 _ => panic!("variant mismatch"),
720 }
721 }
722
723 #[test]
724 fn test_constant_time_compare_truncated() {
725 use crate::helpers::constant_time_compare;
726 let token = generate_token();
727 let truncated = &token[..token.len() - 1];
728 assert!(!constant_time_compare(&token, truncated));
729 }
730 }
731