steid

@jamesgill /

1//! Cross-site request forgery protection for every form.
2//!
3//! **This is defence in depth, not the only defence.** Two things already stand in
4//! front of it. The session cookie is `SameSite=Lax`, which stops a cross-site POST in
5//! every current browser; and Topcoat's `sessions()` installs an
6//! [`OriginLayer`](topcoat::session::OriginLayer) that refuses any state-changing
7//! request whose `Sec-Fetch-Site` is not `same-origin`/`none`, falling back to
8//! comparing `Origin`'s host against the request's own. What that layer deliberately
9//! lets through is a request carrying **neither** header — a non-browser client, which
10//! has no ambient cookies to forge with. A browser old enough to send neither, or a
11//! proxy that strips `Origin`, lands in the same gap, and this is what covers it. It
12//! also covers `SameSite` being loosened later, which is a one-line change somebody
13//! could make for an unrelated reason.
14//!
15//! **The pattern is double-submit bound to the session.** The token is
16//! `HMAC-SHA-256(secret, session token hash)`, rendered hex. Nothing new is stored:
17//! the session already exists on both sides, and the HMAC is what stops a visitor
18//! minting a token for a session that is not theirs. A token is therefore useless
19//! anywhere but in the session it was rendered for, which is the property a plain
20//! random hidden field does not have.
21//!
22//! **The secret is random per boot** ([`CsrfSecret::generate`]), not derived from
23//! anything durable — Steid configures no session secret to derive from
24//! (`SessionConfig::default()` in `main`), so there is nothing else to use. The cost
25//! is that a restart invalidates every form that is already open on someone's screen:
26//! submitting it answers 403 and they reload and retype. That is acceptable for a
27//! form and would not be for a session, which is why the session cookie is not
28//! treated the same way.
29//!
30//! **A request with no session is refused**, uniformly, for every guarded route. Every
31//! guarded route is an owner's, so an anonymous POST to one was going to be refused a
32//! moment later anyway; refusing it here answers the same 403 whether or not the target
33//! exists, so nothing leaks by status code.
34//!
35//! ## What is exempt, and why
36//!
37//! Enumerated in [`EXEMPT`] and asserted by the test at the bottom of this file. Two
38//! kinds:
39//!
40//! - **No cookie and no form.** The git transport (`git-upload-pack`,
41//! `git-receive-pack`) authenticates with HTTP Basic per request, and `/api/setup` is
42//! JSON a provisioner POSTs. Neither is reached from a browser carrying a session, so
43//! there is no ambient authority to forge with and nowhere to put a hidden field.
44//! - **No session to bind to.** `/auth/setup`, `/auth/login`, `/auth/forgot` and
45//! `/auth/reset/{token}` are submitted by someone who is not signed in. A
46//! double-submit token there would be one an attacker can simply fetch, so it would
47//! protect nothing — see the note in `current.md` about login CSRF, which is real and
48//! needs a pre-session cookie rather than this.
49
50use hmac::{Hmac, Mac};
51use serde::Deserialize;
52use sha2::Sha256;
53use subtle::ConstantTimeEq;
54use topcoat::{
55 Result,
56 context::{Cx, app_context},
57 router::{Body, Response, StatusCode},
58 session,
59 view::{component, view},
60};
61
62type HmacSha256 = Hmac<Sha256>;
63
64/// The name of the hidden input, and of the field on every guarded form struct.
65pub const CSRF_FIELD: &str = "csrf_token";
66
67/// What a refused submission is told. One sentence, because there is nothing the
68/// person can usefully do beyond going back — and because saying more would describe
69/// the check to whoever is probing it.
70pub const REFUSED: &str = "This form was submitted from somewhere else. Go back and try again.";
71
72/// The routes that deliberately do not carry a token, with the reason.
73///
74/// Read by the scan test in `csrf_scan.rs`, which fails when a POST route neither calls
75/// the check nor appears here — so a new form cannot forget, and an exemption has to be
76/// written down before it counts.
77pub const EXEMPT: &[(&str, &str)] = &[
78 (
79 "/{handle}/repos/{repo}/git-upload-pack",
80 "git transport: HTTP Basic per request, no cookie and no form",
81 ),
82 (
83 "/{handle}/repos/{repo}/git-receive-pack",
84 "git transport: HTTP Basic per request, no cookie and no form",
85 ),
86 (
87 "/api/setup",
88 "JSON a provisioner POSTs, never a browser carrying a session",
89 ),
90 (
91 "/auth/setup",
92 "submitted signed out: no session to bind a token to",
93 ),
94 (
95 "/auth/login",
96 "submitted signed out: no session to bind a token to",
97 ),
98 (
99 "/auth/forgot",
100 "submitted signed out: no session to bind a token to",
101 ),
102 (
103 "/auth/reset/{token}",
104 "submitted signed out, and the URL's own token is the unguessable part",
105 ),
106];
107
108/// The process-wide secret the tokens are derived from.
109///
110/// Lives in app context, built once in `main`. Built per request it would mint a
111/// different token on every page and verify nothing.
112pub struct CsrfSecret([u8; 32]);
113
114impl CsrfSecret {
115 /// A fresh secret. See the module header for why this is per boot.
116 #[must_use]
117 pub fn generate() -> Self {
118 use rand::Rng;
119
120 let mut bytes = [0u8; 32];
121 rand::rng().fill_bytes(&mut bytes);
122
123 Self(bytes)
124 }
125}
126
127impl std::fmt::Debug for CsrfSecret {
128 /// Redacted, like every other secret in the codebase: a `Debug` of the app context
129 /// must not be a way to read it out of a log.
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str("CsrfSecret(redacted)")
132 }
133}
134
135/// The token for one session under one secret.
136///
137/// Separated from the request so it can be tested without a `Cx`.
138fn derive(secret: &CsrfSecret, session: &[u8]) -> String {
139 // Infallible for HMAC: the construction accepts a key of any length.
140 let mut mac = HmacSha256::new_from_slice(&secret.0).expect("hmac takes any key");
141 mac.update(session);
142
143 mac.finalize()
144 .into_bytes()
145 .iter()
146 .map(|byte| format!("{byte:02x}"))
147 .collect()
148}
149
150/// Whether a presented token is the expected one, without leaking where they differ.
151///
152/// Length is compared by `ct_eq` too, so two values of different length are unequal
153/// without an early return that depends on the first differing byte.
154fn matches(expected: &str, presented: &str) -> bool {
155 expected.as_bytes().ct_eq(presented.as_bytes()).unwrap_u8() == 1
156}
157
158/// This request's token, or `None` when there is no session to bind one to.
159async fn token(cx: &Cx) -> Result<Option<String>> {
160 let Some(hash) = session::token_hash(cx).await? else {
161 return Ok(None);
162 };
163
164 Ok(Some(derive(app_context::<CsrfSecret>(cx), &*hash)))
165}
166
167/// The hidden input every guarded form carries.
168///
169/// A component rather than a helper function because `view!` needs `__cx` in scope. It
170/// declares `cx` and so is invoked bare — `csrf_field()` — with no argument.
171///
172/// Renders an empty value when there is no session. Every guarded route is an owner's,
173/// so the only visitor who sees that is one whose submission is refused either way.
174#[component]
175pub async fn csrf_field(cx: &Cx) -> Result {
176 let token = token(cx).await?.unwrap_or_default();
177
178 view! { <input type="hidden" name=(CSRF_FIELD) value=(token.as_str()) /> }
179}
180
181/// Refuses the submission unless it carries this session's token.
182///
183/// Called by every `#[page(POST …)]` handler **before its use case**, so that nothing
184/// with a side effect runs on a submission that may not be the visitor's own. The
185/// refusal is a **403, not a 404**: the visitor is signed in and the page they were
186/// looking at exists, so the honest answer is that this particular request is not
187/// allowed — and unlike the ownership guards there is nothing to hide, because the
188/// answer does not depend on the target.
189///
190/// # Errors
191///
192/// [`CsrfRefused`] when the token is absent, wrong, or there is no session.
193pub async fn csrf_verified(cx: &Cx, presented: &str) -> Result<()> {
194 match token(cx).await? {
195 Some(expected) if matches(&expected, presented) => Ok(()),
196 _ => {
197 eprintln!("steid: refused a form submission with no valid CSRF token");
198 Err(CsrfRefused.into())
199 }
200 }
201}
202
203/// [`csrf_verified`] for the `#[route(POST …)]` handlers, which return a `Response`
204/// rather than a view.
205///
206/// Layouts wrap views, so a route handler cannot reach the 403 page the layout renders
207/// for [`CsrfRefused`] — it would fall through to a 500. It gets the same sentence as a
208/// plain response instead, in the shape `throttled` already established:
209///
210/// ```ignore
211/// if let Some(response) = csrf_verified_response(cx, &form.csrf_token).await? {
212/// return Ok(response);
213/// }
214/// ```
215///
216/// # Errors
217///
218/// Propagates a session-store failure. A refusal is the `Some` arm, not an error.
219pub async fn csrf_verified_response(cx: &Cx, presented: &str) -> Result<Option<Response>> {
220 match csrf_verified(cx, presented).await {
221 Ok(()) => Ok(None),
222 Err(error) => {
223 if error.downcast_ref::<CsrfRefused>().is_some() {
224 let mut response = Response::new(Body::from(format!("{REFUSED}\n")));
225 *response.status_mut() = StatusCode::FORBIDDEN;
226 Ok(Some(response))
227 } else {
228 Err(error)
229 }
230 }
231 }
232}
233
234/// A submission that did not carry this session's token.
235///
236/// Not one of Topcoat's error types, so the router would render it as a 500 — the root
237/// layout catches it by downcast and renders [`REFUSED`] with a 403 instead. That is
238/// the only way a `#[page]` handler can answer with both a status and a view.
239#[derive(Debug)]
240pub struct CsrfRefused;
241
242impl std::fmt::Display for CsrfRefused {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.write_str("the form did not carry this session's CSRF token")
245 }
246}
247
248impl std::error::Error for CsrfRefused {}
249
250/// The `csrf_token` field on its own, for a POST that otherwise submits nothing —
251/// a delete button, a toggle, signing out.
252///
253/// Without this those handlers took no `Form` at all, and adding a one-field struct to
254/// each of them would be eight copies of the same three lines.
255#[derive(Debug, Deserialize)]
256pub struct CsrfGuard {
257 /// `default` for the reason every guarded form's own field is: a body with no token
258 /// must reach [`csrf_verified`] and be refused with a sentence, rather than failing
259 /// deserialization into a bare 400.
260 #[serde(default)]
261 pub csrf_token: String,
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 /// A fixed secret, so a derivation can be asserted rather than merely compared to
269 /// itself.
270 fn secret(byte: u8) -> CsrfSecret {
271 CsrfSecret([byte; 32])
272 }
273
274 #[test]
275 fn a_token_is_the_hmac_of_the_session_under_the_secret() {
276 // Cross-checked against `openssl dgst -sha256 -mac HMAC -macopt hexkey:<32 * 01>`
277 // over the four bytes `sess`.
278 assert_eq!(
279 derive(&secret(0x01), b"sess"),
280 "8a705977f42fbbc2a31942039d841760ec5de4c812f88e983674c0a5b44f61f7"
281 );
282 }
283
284 #[test]
285 fn two_sessions_get_different_tokens_under_one_secret() {
286 // The whole point of binding: a token minted for one session must not verify
287 // for another, or a signed-in attacker could hand out their own.
288 assert_ne!(derive(&secret(0x01), b"one"), derive(&secret(0x01), b"two"));
289 }
290
291 #[test]
292 fn two_boots_get_different_tokens_for_one_session() {
293 // Why a restart invalidates open forms, stated as a test rather than only in
294 // the header.
295 assert_ne!(derive(&secret(0x01), b"one"), derive(&secret(0x02), b"one"));
296 }
297
298 #[test]
299 fn a_token_verifies_against_itself_and_nothing_else() {
300 let token = derive(&secret(0x01), b"sess");
301
302 assert!(matches(&token, &token));
303 assert!(!matches(&token, ""));
304 assert!(!matches(&token, &token[..token.len() - 1]));
305 assert!(!matches(&token, &format!("{token}0")));
306 assert!(!matches(&token, &derive(&secret(0x01), b"other")));
307 }
308
309 #[test]
310 fn the_secret_does_not_print_itself() {
311 // A secret that shows up in a `Debug` of the app context is a secret in a log.
312 assert_eq!(format!("{:?}", secret(0x01)), "CsrfSecret(redacted)");
313 }
314
315 #[test]
316 fn every_exemption_says_why() {
317 for (route, reason) in EXEMPT {
318 assert!(route.starts_with('/'), "{route} is not a route");
319 assert!(!reason.is_empty(), "{route} is exempt for no stated reason");
320 }
321 }
322}