| 143 |
143 |
|
}
|
| 144 |
144 |
|
}
|
| 145 |
145 |
|
|
| 146 |
|
- |
/// Extract CSRF token from request (header or form field)
|
|
146 |
+ |
/// Token for a Manual-posture handler, which has an already-parsed form rather
|
|
147 |
+ |
/// than a raw body: the `X-CSRF-Token` header first, then the deserialized
|
|
148 |
+ |
/// `_csrf` field.
|
|
149 |
+ |
///
|
|
150 |
+ |
/// Separate from [`extract_token_from_request`] because that one takes the raw
|
|
151 |
+ |
/// urlencoded *body* and searches it for a `_csrf=` key. Handing it the field's
|
|
152 |
+ |
/// *value* instead looks right and silently yields `None`: the parser reads the
|
|
153 |
+ |
/// token itself as a key with an empty value, finds no `_csrf`, and the caller's
|
|
154 |
+ |
/// `unwrap_or_default()` turns that into an empty token that can never validate.
|
|
155 |
+ |
///
|
|
156 |
+ |
/// Both Manual call sites did exactly that, so `POST /login` and the vanilla tip
|
|
157 |
+ |
/// form answered 403 to every request that did not come from HTMX. HTMX sends
|
|
158 |
+ |
/// the header and takes the first branch, which is why the whole site worked and
|
|
159 |
+ |
/// only the no-JS form path was dead. Found 2026-08-07 driving the landing
|
|
160 |
+ |
/// carousel capture, which posts a plain form on purpose.
|
|
161 |
+ |
pub fn token_from_header_or_field(headers: &HeaderMap, field: Option<&str>) -> Option<String> {
|
|
162 |
+ |
if let Some(token) = headers
|
|
163 |
+ |
.get("X-CSRF-Token")
|
|
164 |
+ |
.and_then(|v| v.to_str().ok())
|
|
165 |
+ |
.map(std::string::ToString::to_string)
|
|
166 |
+ |
{
|
|
167 |
+ |
return Some(token);
|
|
168 |
+ |
}
|
|
169 |
+ |
field.map(std::string::ToString::to_string)
|
|
170 |
+ |
}
|
|
171 |
+ |
|
|
172 |
+ |
/// Extract CSRF token from request (header or the raw form-encoded **body**).
|
|
173 |
+ |
///
|
|
174 |
+ |
/// `body` is the whole urlencoded body, not a single field. A handler holding a
|
|
175 |
+ |
/// deserialized form wants [`token_from_header_or_field`] instead.
|
| 147 |
176 |
|
pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Option<String> {
|
| 148 |
177 |
|
// Try the X-CSRF-Token header (used by HTMX)
|
| 149 |
178 |
|
if let Some(token) = headers
|
| 767 |
796 |
|
mod tests {
|
| 768 |
797 |
|
use super::*;
|
| 769 |
798 |
|
|
|
799 |
+ |
/// The bug this pair of functions exists to keep apart.
|
|
800 |
+ |
///
|
|
801 |
+ |
/// A Manual-posture handler holds `form.csrf`, the token's value. Feeding
|
|
802 |
+ |
/// that to the body parser reads the token as a key with an empty value and
|
|
803 |
+ |
/// finds no `_csrf`, so the caller gets `None`, defaults it to "", and every
|
|
804 |
+ |
/// vanilla form post answers 403. Only HTMX worked, because it takes the
|
|
805 |
+ |
/// header branch above.
|
|
806 |
+ |
#[test]
|
|
807 |
+ |
fn a_bare_field_value_is_not_a_form_body() {
|
|
808 |
+ |
let token = "a".repeat(64);
|
|
809 |
+ |
let empty = HeaderMap::new();
|
|
810 |
+ |
|
|
811 |
+ |
assert_eq!(
|
|
812 |
+ |
extract_token_from_request(&empty, Some(&token)),
|
|
813 |
+ |
None,
|
|
814 |
+ |
"the body parser must not find a token in a bare field value"
|
|
815 |
+ |
);
|
|
816 |
+ |
assert_eq!(
|
|
817 |
+ |
token_from_header_or_field(&empty, Some(&token)),
|
|
818 |
+ |
Some(token.clone()),
|
|
819 |
+ |
"the field helper must take the value as given"
|
|
820 |
+ |
);
|
|
821 |
+ |
assert_eq!(
|
|
822 |
+ |
extract_token_from_request(&empty, Some(&format!("login=x&_csrf={token}"))),
|
|
823 |
+ |
Some(token),
|
|
824 |
+ |
"the body parser must still read a real urlencoded body"
|
|
825 |
+ |
);
|
|
826 |
+ |
}
|
|
827 |
+ |
|
|
828 |
+ |
#[test]
|
|
829 |
+ |
fn the_header_outranks_the_field() {
|
|
830 |
+ |
let mut headers = HeaderMap::new();
|
|
831 |
+ |
headers.insert("X-CSRF-Token", "from-header".parse().unwrap());
|
|
832 |
+ |
assert_eq!(
|
|
833 |
+ |
token_from_header_or_field(&headers, Some("from-field")),
|
|
834 |
+ |
Some("from-header".to_string())
|
|
835 |
+ |
);
|
|
836 |
+ |
assert_eq!(token_from_header_or_field(&HeaderMap::new(), None), None);
|
|
837 |
+ |
}
|
|
838 |
+ |
|
| 770 |
839 |
|
#[test]
|
| 771 |
840 |
|
fn test_generate_token() {
|
| 772 |
841 |
|
let token1 = generate_token();
|