Skip to main content

aster_forge_middleware/shared/
csrf.rs

1//! Framework-neutral CSRF token and request-source validation.
2use http::{
3    Method,
4    header::{HeaderName, InvalidHeaderName},
5};
6use rand::RngExt;
7use std::sync::OnceLock;
8use subtle::ConstantTimeEq;
9
10/// Default CSRF cookie name used by compatibility helpers.
11///
12/// Prefer [`CsrfTokenNames`] when a product can share a browser origin with another Aster service.
13pub const CSRF_COOKIE: &str = "aster_csrf";
14/// Default CSRF request header name used by compatibility helpers.
15///
16/// Prefer [`CsrfTokenNames`] when a product can share a browser origin with another Aster service.
17pub const CSRF_HEADER: &str = "X-CSRF-Token";
18const DEFAULT_CSRF_HEADER_LOWER: &str = "x-csrf-token";
19
20const MAX_REQUEST_SCHEME_LEN: usize = 16;
21const MAX_REQUEST_HOST_LEN: usize = 512;
22const MAX_REFERER_AUTHORITY_LEN: usize = MAX_REQUEST_HOST_LEN + 16;
23const MAX_SOURCE_HEADER_LEN: usize = 2048;
24const MAX_SEC_FETCH_SITE_LEN: usize = 64;
25
26/// Whether source headers are required or only validated when present.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RequestSourceMode {
29    /// Accept requests without source headers, but validate them when present.
30    OptionalWhenPresent,
31    /// Require a trusted `Origin` or `Referer` header for unsafe cookie-authenticated actions.
32    Required,
33}
34
35/// Product-neutral CSRF failure category.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CsrfErrorKind {
38    /// A configured CSRF cookie or header name was invalid.
39    ///
40    /// This can only be returned while constructing [`CsrfTokenNames`], not while validating a
41    /// normal request.
42    TokenNameInvalid,
43    /// The CSRF cookie was missing.
44    CookieMissing,
45    /// The CSRF header was missing.
46    HeaderMissing,
47    /// The CSRF cookie and header did not match.
48    TokenInvalid,
49    /// `Sec-Fetch-Site` reported an untrusted source.
50    RequestSourceUntrusted,
51    /// `Origin` was present but not trusted.
52    RequestOriginUntrusted,
53    /// `Referer` was present but not trusted.
54    RequestRefererUntrusted,
55    /// Required source headers were missing.
56    RequestSourceMissing,
57    /// Request scheme was malformed or too long.
58    RequestSchemeInvalid,
59    /// Request host was malformed or too long.
60    RequestHostInvalid,
61    /// Origin header was malformed or too long.
62    RequestOriginInvalid,
63    /// Referer header was malformed or too long.
64    RequestRefererInvalid,
65    /// Generic source header validation failure.
66    RequestHeaderValueInvalid,
67}
68
69/// Error returned by CSRF helper functions.
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71#[error("{message}")]
72pub struct CsrfError {
73    kind: CsrfErrorKind,
74    message: String,
75}
76
77impl CsrfError {
78    pub(crate) fn new(kind: CsrfErrorKind, message: impl Into<String>) -> Self {
79        Self {
80            kind,
81            message: message.into(),
82        }
83    }
84
85    /// Returns the product-neutral failure category.
86    #[must_use]
87    pub fn kind(&self) -> CsrfErrorKind {
88        self.kind
89    }
90
91    /// Returns the diagnostic message.
92    #[must_use]
93    pub fn message(&self) -> &str {
94        &self.message
95    }
96}
97
98/// Result type returned by CSRF helper functions.
99pub type Result<T> = std::result::Result<T, CsrfError>;
100
101/// Cookie and header names used by the double-submit token check.
102///
103/// Services that share a browser origin should configure service-specific names during startup to
104/// avoid cookie/header collisions. Store this value in the product's startup state, app data, or a
105/// process-wide `OnceLock`; do not switch names while a process is serving traffic because active
106/// browser sessions would still hold the previous cookie name and frontend code may still send the
107/// previous header.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct CsrfTokenNames {
110    cookie_name: String,
111    header_name: HeaderName,
112}
113
114impl CsrfTokenNames {
115    /// Builds CSRF token names after validating the cookie and header names.
116    ///
117    /// Cookie names are validated against the conservative RFC 6265 token character set. Header
118    /// names are parsed through the framework-neutral HTTP header type and stored in lower-case
119    /// form, which makes comparisons and CORS allow-list generation stable.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`CsrfError`] when either token name is empty or contains invalid characters.
124    pub fn new(cookie_name: impl Into<String>, header_name: impl AsRef<str>) -> Result<Self> {
125        let cookie_name = cookie_name.into();
126        validate_cookie_name(&cookie_name)?;
127        let header_name = parse_header_name(header_name.as_ref())?;
128        Ok(Self {
129            cookie_name,
130            header_name,
131        })
132    }
133
134    /// Returns the configured CSRF cookie name.
135    pub fn cookie_name(&self) -> &str {
136        &self.cookie_name
137    }
138
139    /// Returns the configured CSRF request header name.
140    pub fn header_name(&self) -> &HeaderName {
141        &self.header_name
142    }
143
144    /// Returns the configured CSRF request header name as a lower-case string.
145    ///
146    /// This is useful when building `Access-Control-Allow-Headers` values for browser preflight
147    /// responses.
148    pub fn header_name_str(&self) -> &str {
149        self.header_name.as_str()
150    }
151}
152
153impl Default for CsrfTokenNames {
154    fn default() -> Self {
155        Self {
156            cookie_name: CSRF_COOKIE.to_string(),
157            header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER_LOWER),
158        }
159    }
160}
161
162/// Returns the shared default CSRF token names.
163///
164/// This is intended for compatibility helpers and tests. Product integrations that support
165/// service-specific names should construct and store their own [`CsrfTokenNames`] instead.
166pub fn default_csrf_token_names() -> &'static CsrfTokenNames {
167    static DEFAULT_NAMES: OnceLock<CsrfTokenNames> = OnceLock::new();
168    DEFAULT_NAMES.get_or_init(CsrfTokenNames::default)
169}
170
171/// Returns whether `method` can mutate state and should be protected by CSRF checks.
172#[must_use]
173pub fn is_unsafe_method(method: &Method) -> bool {
174    !matches!(
175        *method,
176        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
177    )
178}
179
180/// Builds a URL-safe random CSRF token.
181#[must_use]
182pub fn build_csrf_token() -> String {
183    use base64::Engine;
184
185    let mut bytes = [0_u8; 32];
186    rand::rng().fill(&mut bytes);
187    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
188}
189
190/// Validates raw source header values against the request and public-site origins.
191///
192/// # Errors
193///
194/// Returns [`CsrfError`] when a header is too long, malformed, missing when required, or untrusted.
195pub fn ensure_headers_allowed(
196    origin: Option<&str>,
197    referer: Option<&str>,
198    sec_fetch_site: Option<&str>,
199    request_origin: &str,
200    public_site_origins: &[String],
201    mode: RequestSourceMode,
202) -> Result<()> {
203    let fetch_site = source_header_value(
204        sec_fetch_site,
205        MAX_SEC_FETCH_SITE_LEN,
206        "Sec-Fetch-Site",
207        CsrfErrorKind::RequestHeaderValueInvalid,
208    )?
209    .map(str::to_ascii_lowercase);
210
211    if fetch_site.as_ref().is_some_and(|value| !value.is_ascii()) {
212        return Err(CsrfError::new(
213            CsrfErrorKind::RequestHeaderValueInvalid,
214            "invalid Sec-Fetch-Site header",
215        ));
216    }
217
218    if let Some("cross-site" | "none") = fetch_site.as_deref() {
219        return Err(CsrfError::new(
220            CsrfErrorKind::RequestSourceUntrusted,
221            "untrusted request source for cookie-authenticated action",
222        ));
223    }
224    let same_site_fetch = fetch_site.as_deref() == Some("same-site");
225
226    if let Some(origin) = source_header_value(
227        origin,
228        MAX_SOURCE_HEADER_LEN,
229        "Origin",
230        CsrfErrorKind::RequestOriginInvalid,
231    )?
232    .map(|value| normalize_origin(value, CsrfErrorKind::RequestOriginInvalid))
233    .transpose()?
234    {
235        if origin_is_trusted(&origin, request_origin, public_site_origins) {
236            return Ok(());
237        }
238        return Err(CsrfError::new(
239            CsrfErrorKind::RequestOriginUntrusted,
240            "untrusted request origin for cookie-authenticated action",
241        ));
242    }
243
244    if let Some(referer) = trimmed_header_value(referer) {
245        let referer_origin = origin_from_url(referer)?;
246        if origin_is_trusted(&referer_origin, request_origin, public_site_origins) {
247            return Ok(());
248        }
249        return Err(CsrfError::new(
250            CsrfErrorKind::RequestRefererUntrusted,
251            "untrusted request referer for cookie-authenticated action",
252        ));
253    }
254
255    if same_site_fetch {
256        return Err(CsrfError::new(
257            CsrfErrorKind::RequestSourceUntrusted,
258            "missing trusted request source for same-site cookie-authenticated action",
259        ));
260    }
261
262    match mode {
263        RequestSourceMode::OptionalWhenPresent => Ok(()),
264        RequestSourceMode::Required => Err(CsrfError::new(
265            CsrfErrorKind::RequestSourceMissing,
266            "missing request source for cookie-authenticated action",
267        )),
268    }
269}
270
271fn validate_cookie_name(cookie_name: &str) -> Result<()> {
272    if cookie_name.is_empty() {
273        return Err(CsrfError::new(
274            CsrfErrorKind::TokenNameInvalid,
275            "CSRF cookie name cannot be empty",
276        ));
277    }
278    if cookie_name
279        .bytes()
280        .any(|byte| byte <= 0x20 || byte >= 0x7f || b"()<>@,;:\\\"/[]?={}".contains(&byte))
281    {
282        return Err(CsrfError::new(
283            CsrfErrorKind::TokenNameInvalid,
284            "CSRF cookie name contains invalid characters",
285        ));
286    }
287    Ok(())
288}
289
290fn parse_header_name(header_name: &str) -> Result<HeaderName> {
291    HeaderName::from_bytes(header_name.as_bytes()).map_err(|error| header_name_error(&error))
292}
293
294fn header_name_error(error: &InvalidHeaderName) -> CsrfError {
295    CsrfError::new(
296        CsrfErrorKind::TokenNameInvalid,
297        format!("invalid CSRF header name: {error}"),
298    )
299}
300
301/// Builds a bounded, normalized origin from product-trusted scheme and host values.
302///
303/// # Errors
304/// Returns a classified error for malformed or oversized input.
305pub fn request_origin(scheme: &str, host: &str) -> Result<String> {
306    ensure_value_len(
307        scheme,
308        MAX_REQUEST_SCHEME_LEN,
309        "request scheme",
310        CsrfErrorKind::RequestSchemeInvalid,
311    )?;
312    ensure_value_len(
313        host,
314        MAX_REQUEST_HOST_LEN,
315        "request host",
316        CsrfErrorKind::RequestHostInvalid,
317    )?;
318    normalize_origin(
319        &format!("{scheme}://{host}"),
320        CsrfErrorKind::RequestHostInvalid,
321    )
322    .map_err(|_| CsrfError::new(CsrfErrorKind::RequestHostInvalid, "invalid request host"))
323}
324
325fn normalize_origin(origin: &str, kind: CsrfErrorKind) -> Result<String> {
326    aster_forge_utils::url::normalize_origin(origin, false)
327        .map_err(|_| CsrfError::new(kind, "invalid origin"))
328}
329
330fn origin_is_trusted(origin: &str, request_origin: &str, public_site_origins: &[String]) -> bool {
331    origin == request_origin || public_site_origins.iter().any(|allowed| allowed == origin)
332}
333
334fn source_header_value<'a>(
335    value: Option<&'a str>,
336    max_len: usize,
337    label: &str,
338    kind: CsrfErrorKind,
339) -> Result<Option<&'a str>> {
340    let Some(value) = trimmed_header_value(value) else {
341        return Ok(None);
342    };
343    ensure_value_len(value, max_len, label, kind)?;
344    Ok(Some(value))
345}
346
347fn trimmed_header_value(value: Option<&str>) -> Option<&str> {
348    value.map(str::trim).filter(|value| !value.is_empty())
349}
350
351fn ensure_value_len(value: &str, max_len: usize, label: &str, kind: CsrfErrorKind) -> Result<()> {
352    if value.len() > max_len {
353        return Err(CsrfError::new(
354            kind,
355            format!("{label} exceeds {max_len} bytes"),
356        ));
357    }
358    Ok(())
359}
360
361fn origin_from_url(url: &str) -> Result<String> {
362    let scheme_end = url.find("://").ok_or_else(|| {
363        CsrfError::new(
364            CsrfErrorKind::RequestSchemeInvalid,
365            "invalid Referer header",
366        )
367    })?;
368    let scheme = &url[..scheme_end];
369    ensure_value_len(
370        scheme,
371        MAX_REQUEST_SCHEME_LEN,
372        "Referer scheme",
373        CsrfErrorKind::RequestSchemeInvalid,
374    )?;
375
376    let authority_start = scheme_end + 3;
377    let authority_tail = &url[authority_start..];
378    let authority_end = authority_tail
379        .char_indices()
380        .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(authority_start + idx))
381        .unwrap_or(url.len());
382    let authority = &url[authority_start..authority_end];
383    ensure_value_len(
384        authority,
385        MAX_REFERER_AUTHORITY_LEN,
386        "Referer authority",
387        CsrfErrorKind::RequestRefererInvalid,
388    )?;
389
390    normalize_origin(
391        &format!("{}://{}", scheme.to_ascii_lowercase(), authority),
392        CsrfErrorKind::RequestRefererInvalid,
393    )
394    .map_err(|_| {
395        CsrfError::new(
396            CsrfErrorKind::RequestRefererInvalid,
397            "invalid Referer header",
398        )
399    })
400}
401
402/// Compares decoded double-submit values in constant time for equal-length tokens.
403///
404/// # Errors
405/// Returns a classified error for missing, empty, or mismatched values.
406pub fn ensure_token_values(cookie: Option<&str>, header: Option<&str>) -> Result<()> {
407    let cookie = cookie
408        .ok_or_else(|| CsrfError::new(CsrfErrorKind::CookieMissing, "missing CSRF cookie"))?;
409    let header = header
410        .map(str::trim)
411        .filter(|value| !value.is_empty())
412        .ok_or_else(|| CsrfError::new(CsrfErrorKind::HeaderMissing, "missing CSRF header"))?;
413    // Issued token lengths are public. Compare the secret bytes in constant time.
414    if header.len() != cookie.len() || !bool::from(header.as_bytes().ct_eq(cookie.as_bytes())) {
415        return Err(CsrfError::new(
416            CsrfErrorKind::TokenInvalid,
417            "invalid CSRF token",
418        ));
419    }
420    Ok(())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    #[test]
427    fn token_comparison_checks_every_byte_and_public_length() {
428        let token = build_csrf_token();
429        assert!(ensure_token_values(Some(&token), Some(&token)).is_ok());
430        for index in 0..token.len() {
431            let mut changed = token.as_bytes().to_vec();
432            changed[index] = if changed[index] == b'a' { b'b' } else { b'a' };
433            let changed = String::from_utf8(changed).unwrap();
434            assert_eq!(
435                ensure_token_values(Some(&token), Some(&changed))
436                    .unwrap_err()
437                    .kind(),
438                CsrfErrorKind::TokenInvalid
439            );
440        }
441        assert_eq!(
442            ensure_token_values(Some(&token), Some(&token[..token.len() - 1]))
443                .unwrap_err()
444                .kind(),
445            CsrfErrorKind::TokenInvalid
446        );
447    }
448    #[test]
449    fn accepts_same_origin_and_public_site_origin() {
450        assert!(
451            ensure_headers_allowed(
452                Some("http://localhost"),
453                None,
454                Some("same-origin"),
455                "http://localhost",
456                &["https://forge.example.com".to_string()],
457                RequestSourceMode::Required,
458            )
459            .is_ok()
460        );
461
462        assert!(
463            ensure_headers_allowed(
464                Some("https://forge.example.com"),
465                None,
466                Some("same-origin"),
467                "http://127.0.0.1:3000",
468                &["https://forge.example.com".to_string()],
469                RequestSourceMode::Required,
470            )
471            .is_ok()
472        );
473    }
474    #[test]
475    fn same_site_fetch_metadata_requires_trusted_origin_or_referer() {
476        assert!(
477            ensure_headers_allowed(
478                Some("https://panel.example.com"),
479                None,
480                Some("same-site"),
481                "https://api.example.com",
482                &[
483                    "https://api.example.com".to_string(),
484                    "https://panel.example.com".to_string(),
485                ],
486                RequestSourceMode::OptionalWhenPresent,
487            )
488            .is_ok()
489        );
490
491        assert!(
492            ensure_headers_allowed(
493                None,
494                Some("https://panel.example.com/settings"),
495                Some("same-site"),
496                "https://api.example.com",
497                &[
498                    "https://api.example.com".to_string(),
499                    "https://panel.example.com".to_string(),
500                ],
501                RequestSourceMode::OptionalWhenPresent,
502            )
503            .is_ok()
504        );
505
506        let err = ensure_headers_allowed(
507            None,
508            None,
509            Some("same-site"),
510            "https://api.example.com",
511            &["https://api.example.com".to_string()],
512            RequestSourceMode::OptionalWhenPresent,
513        )
514        .unwrap_err();
515        assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
516        assert!(err.message().contains("missing trusted request source"));
517    }
518    #[test]
519    fn rejects_untrusted_fetch_metadata_values() {
520        for fetch_site in ["cross-site", "none"] {
521            let err = ensure_headers_allowed(
522                None,
523                None,
524                Some(fetch_site),
525                "https://forge.example.com",
526                &[],
527                RequestSourceMode::OptionalWhenPresent,
528            )
529            .unwrap_err();
530            assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
531            assert!(err.message().contains("untrusted request source"));
532        }
533    }
534    #[test]
535    fn rejects_untrusted_origin_and_missing_required_source() {
536        let err = ensure_headers_allowed(
537            Some("https://evil.example.com"),
538            None,
539            None,
540            "https://forge.example.com",
541            &[],
542            RequestSourceMode::OptionalWhenPresent,
543        )
544        .unwrap_err();
545        assert_eq!(err.kind(), CsrfErrorKind::RequestOriginUntrusted);
546
547        let err = ensure_headers_allowed(
548            None,
549            None,
550            None,
551            "https://forge.example.com",
552            &[],
553            RequestSourceMode::Required,
554        )
555        .unwrap_err();
556        assert_eq!(err.kind(), CsrfErrorKind::RequestSourceMissing);
557    }
558    #[test]
559    fn referer_source_check_ignores_long_path_after_bounded_origin() {
560        let long_referer = format!("https://forge.example.com/settings/{}", "a".repeat(10_000));
561
562        assert!(
563            ensure_headers_allowed(
564                None,
565                Some(&long_referer),
566                Some("same-origin"),
567                "https://forge.example.com",
568                &[],
569                RequestSourceMode::Required,
570            )
571            .is_ok()
572        );
573    }
574    #[test]
575    fn invalid_referer_missing_scheme_reports_invalid_scheme() {
576        let err = ensure_headers_allowed(
577            None,
578            Some("forge.example.com/settings"),
579            None,
580            "https://forge.example.com",
581            &[],
582            RequestSourceMode::OptionalWhenPresent,
583        )
584        .unwrap_err();
585
586        assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
587    }
588    #[test]
589    fn accepts_missing_optional_source() {
590        assert!(
591            ensure_headers_allowed(
592                None,
593                None,
594                None,
595                "https://forge.example.com",
596                &[],
597                RequestSourceMode::OptionalWhenPresent,
598            )
599            .is_ok()
600        );
601    }
602    #[test]
603    fn build_csrf_token_returns_url_safe_random_value() {
604        let token_a = build_csrf_token();
605        let token_b = build_csrf_token();
606
607        assert_ne!(token_a, token_b);
608        assert!(token_a.len() >= 32);
609        assert!(
610            token_a
611                .chars()
612                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
613        );
614    }
615    #[test]
616    fn csrf_token_names_reject_invalid_cookie_and_header_names() {
617        let err = CsrfTokenNames::new("", "X-CSRF-Token").unwrap_err();
618        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
619
620        let err = CsrfTokenNames::new("aster csrf", "X-CSRF-Token").unwrap_err();
621        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
622
623        let err = CsrfTokenNames::new("aster_csrf", "bad header").unwrap_err();
624        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
625    }
626}