Skip to main content

max / makenotwork

Let a plain form log in POST /login answered 403 to every request that did not come from HTMX, and the vanilla tip form did the same. Both Manual-posture handlers hold a deserialized form, so they passed form.csrf to extract_token_from_request. That function takes the raw urlencoded body and searches it for a `_csrf=` key. Handed the token's value instead, the parser reads the token itself as a key with an empty value, finds no `_csrf`, and returns None, which unwrap_or_default turns into an empty token that can never validate. HTMX sends X-CSRF-Token and takes the header branch, which is why the site worked and only the no-JS path was dead. The login page really does render a <form method="post" action="/login"> as its no-script fallback, and that fallback could not have worked since the Manual posture landed. So give the Manual callers their own helper, token_from_header_or_field, and say in both doc comments why the two are not interchangeable. Found driving the landing carousel capture, which posts a detached plain form on purpose because the page's own form is HTMX and swaps errors into a div.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 22:05 UTC
Signed with PGP, not checked
Commit: e5bfbea758a81ce1deb0917387b652be9d979ced
Parent: e6fb77a
3 files changed, +72 insertions, -3 deletions
@@ -143,7 +143,36 @@
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,6 +796,46 @@
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();
@@ -101,7 +101,7 @@
101 101 // session creation). Match the standard validator's header-then-form
102 102 // precedence so HTMX callers and vanilla form posts both pass.
103 103 let token =
104 - crate::csrf::extract_token_from_request(&headers, form.csrf.as_deref()).unwrap_or_default();
104 + crate::csrf::token_from_header_or_field(&headers, form.csrf.as_deref()).unwrap_or_default();
105 105 let _validated = crate::csrf::validate_token_consuming(&session, &token).await?;
106 106
107 107 let submitted_login = form.login.clone();
@@ -59,7 +59,7 @@
59 59 // success; the binding is `_` because the witness exists only to prove
60 60 // the check happened, not to be passed downstream.
61 61 let token =
62 - csrf::extract_token_from_request(&headers, form.csrf.as_deref()).unwrap_or_default();
62 + csrf::token_from_header_or_field(&headers, form.csrf.as_deref()).unwrap_or_default();
63 63 let _validated = csrf::validate_token_consuming(&session, &token).await?;
64 64 user.check_not_sandbox()?;
65 65 user.check_not_suspended()?;