aster_forge_actix_middleware/
csrf.rs

1//! CSRF helpers for Actix Web services.
2//!
3//! This module implements product-neutral CSRF mechanics: URL-safe token generation, double-submit
4//! cookie/header checks, and request source validation using `Origin`, `Referer`, and
5//! `Sec-Fetch-Site`. Callers map [`CsrfErrorKind`] into their own product error codes.
6//!
7//! The default cookie and header names are compatibility defaults, not a requirement. Services
8//! that share a browser origin should pass [`CsrfTokenNames`] into the `*_with_names` helpers so
9//! each product can use names that will not collide with other products on the same domain.
10
11use actix_web::{
12    HttpRequest,
13    dev::ServiceRequest,
14    http::{
15        Method, header,
16        header::{HeaderName, InvalidHeaderName},
17    },
18};
19use rand::RngExt;
20use std::sync::OnceLock;
21use subtle::ConstantTimeEq;
22
23/// Default CSRF cookie name used by compatibility helpers.
24///
25/// Prefer [`CsrfTokenNames`] when a product can share a browser origin with another Aster service.
26pub const CSRF_COOKIE: &str = "aster_csrf";
27/// Default CSRF request header name used by compatibility helpers.
28///
29/// Prefer [`CsrfTokenNames`] when a product can share a browser origin with another Aster service.
30pub const CSRF_HEADER: &str = "X-CSRF-Token";
31const DEFAULT_CSRF_HEADER_LOWER: &str = "x-csrf-token";
32
33const MAX_REQUEST_SCHEME_LEN: usize = 16;
34const MAX_REQUEST_HOST_LEN: usize = 512;
35const MAX_REFERER_AUTHORITY_LEN: usize = MAX_REQUEST_HOST_LEN + 16;
36const MAX_SOURCE_HEADER_LEN: usize = 2048;
37const MAX_SEC_FETCH_SITE_LEN: usize = 64;
38
39/// Whether source headers are required or only validated when present.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum RequestSourceMode {
42    /// Accept requests without source headers, but validate them when present.
43    OptionalWhenPresent,
44    /// Require a trusted `Origin` or `Referer` header for unsafe cookie-authenticated actions.
45    Required,
46}
47
48/// Product-neutral CSRF failure category.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CsrfErrorKind {
51    /// A configured CSRF cookie or header name was invalid.
52    ///
53    /// This can only be returned while constructing [`CsrfTokenNames`], not while validating a
54    /// normal request.
55    TokenNameInvalid,
56    /// The CSRF cookie was missing.
57    CookieMissing,
58    /// The CSRF header was missing.
59    HeaderMissing,
60    /// The CSRF cookie and header did not match.
61    TokenInvalid,
62    /// `Sec-Fetch-Site` reported an untrusted source.
63    RequestSourceUntrusted,
64    /// `Origin` was present but not trusted.
65    RequestOriginUntrusted,
66    /// `Referer` was present but not trusted.
67    RequestRefererUntrusted,
68    /// Required source headers were missing.
69    RequestSourceMissing,
70    /// Request scheme was malformed or too long.
71    RequestSchemeInvalid,
72    /// Request host was malformed or too long.
73    RequestHostInvalid,
74    /// Origin header was malformed or too long.
75    RequestOriginInvalid,
76    /// Referer header was malformed or too long.
77    RequestRefererInvalid,
78    /// Generic source header validation failure.
79    RequestHeaderValueInvalid,
80}
81
82/// Error returned by CSRF helper functions.
83#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
84#[error("{message}")]
85pub struct CsrfError {
86    kind: CsrfErrorKind,
87    message: String,
88}
89
90impl CsrfError {
91    fn new(kind: CsrfErrorKind, message: impl Into<String>) -> Self {
92        Self {
93            kind,
94            message: message.into(),
95        }
96    }
97
98    /// Returns the product-neutral failure category.
99    #[must_use]
100    pub fn kind(&self) -> CsrfErrorKind {
101        self.kind
102    }
103
104    /// Returns the diagnostic message.
105    #[must_use]
106    pub fn message(&self) -> &str {
107        &self.message
108    }
109}
110
111/// Result type returned by CSRF helper functions.
112pub type Result<T> = std::result::Result<T, CsrfError>;
113
114/// Cookie and header names used by the double-submit token check.
115///
116/// Services that share a browser origin should configure service-specific names during startup to
117/// avoid cookie/header collisions. Store this value in the product's startup state, app data, or a
118/// process-wide `OnceLock`; do not switch names while a process is serving traffic because active
119/// browser sessions would still hold the previous cookie name and frontend code may still send the
120/// previous header.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct CsrfTokenNames {
123    cookie_name: String,
124    header_name: HeaderName,
125}
126
127impl CsrfTokenNames {
128    /// Builds CSRF token names after validating the cookie and header names.
129    ///
130    /// Cookie names are validated against the conservative RFC 6265 token character set. Header
131    /// names are parsed through Actix's HTTP header type and are stored in canonical lower-case
132    /// form, which makes comparisons and CORS allow-list generation stable.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`CsrfError`] when either token name is empty or contains invalid characters.
137    pub fn new(cookie_name: impl Into<String>, header_name: impl AsRef<str>) -> Result<Self> {
138        let cookie_name = cookie_name.into();
139        validate_cookie_name(&cookie_name)?;
140        let header_name = parse_header_name(header_name.as_ref())?;
141        Ok(Self {
142            cookie_name,
143            header_name,
144        })
145    }
146
147    /// Returns the configured CSRF cookie name.
148    pub fn cookie_name(&self) -> &str {
149        &self.cookie_name
150    }
151
152    /// Returns the configured CSRF request header name.
153    pub fn header_name(&self) -> &HeaderName {
154        &self.header_name
155    }
156
157    /// Returns the configured CSRF request header name as a lower-case string.
158    ///
159    /// This is useful when building `Access-Control-Allow-Headers` values for browser preflight
160    /// responses.
161    pub fn header_name_str(&self) -> &str {
162        self.header_name.as_str()
163    }
164}
165
166impl Default for CsrfTokenNames {
167    fn default() -> Self {
168        Self {
169            cookie_name: CSRF_COOKIE.to_string(),
170            header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER_LOWER),
171        }
172    }
173}
174
175/// Returns the shared default CSRF token names.
176///
177/// This is intended for compatibility helpers and tests. Product integrations that support
178/// service-specific names should construct and store their own [`CsrfTokenNames`] instead.
179pub fn default_csrf_token_names() -> &'static CsrfTokenNames {
180    static DEFAULT_NAMES: OnceLock<CsrfTokenNames> = OnceLock::new();
181    DEFAULT_NAMES.get_or_init(CsrfTokenNames::default)
182}
183
184/// Returns whether `method` can mutate state and should be protected by CSRF checks.
185#[must_use]
186pub fn is_unsafe_method(method: &Method) -> bool {
187    !matches!(
188        *method,
189        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
190    )
191}
192
193/// Builds a URL-safe random CSRF token.
194#[must_use]
195pub fn build_csrf_token() -> String {
196    use base64::Engine;
197
198    let mut bytes = [0_u8; 32];
199    rand::rng().fill(&mut bytes);
200    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
201}
202
203/// Ensures an Actix request contains matching CSRF cookie and header values.
204///
205/// This uses [`default_csrf_token_names`]. Prefer [`ensure_double_submit_token_with_names`] in
206/// products that can run beside another Aster service on the same browser origin.
207///
208/// # Errors
209///
210/// Returns [`CsrfError`] when the cookie or header is missing, empty, or does not match.
211pub fn ensure_double_submit_token(req: &HttpRequest) -> Result<()> {
212    ensure_double_submit_token_with_names(req, default_csrf_token_names())
213}
214
215/// Ensures an Actix request contains matching CSRF cookie and header values using custom names.
216///
217/// The helper only performs the double-submit comparison. Product middleware should decide when to
218/// call it, usually for unsafe methods authenticated by cookies. Pair it with request-source
219/// validation to reject cross-site writes before checking the token value.
220///
221/// # Errors
222///
223/// Returns [`CsrfError`] when the configured cookie or header is missing, empty, or does not match.
224pub fn ensure_double_submit_token_with_names(
225    req: &HttpRequest,
226    names: &CsrfTokenNames,
227) -> Result<()> {
228    let cookie_token = req
229        .cookie(names.cookie_name())
230        .map(|cookie| cookie.value().to_string())
231        .ok_or_else(|| CsrfError::new(CsrfErrorKind::CookieMissing, "missing CSRF cookie"))?;
232    let header_token = req
233        .headers()
234        .get(names.header_name())
235        .and_then(|value| value.to_str().ok())
236        .map(str::trim)
237        .filter(|value| !value.is_empty())
238        .ok_or_else(|| {
239            CsrfError::new(
240                CsrfErrorKind::HeaderMissing,
241                format!("missing {} header", names.header_name_str()),
242            )
243        })?;
244
245    // The token length is not secret (issued tokens are fixed-length random
246    // values), so the length pre-check leaks nothing; the byte comparison runs
247    // in constant time to avoid a timing side channel on the token value.
248    let tokens_match = header_token.len() == cookie_token.len()
249        && bool::from(header_token.as_bytes().ct_eq(cookie_token.as_bytes()));
250    if !tokens_match {
251        return Err(CsrfError::new(
252            CsrfErrorKind::TokenInvalid,
253            "invalid CSRF token",
254        ));
255    }
256
257    Ok(())
258}
259
260/// Ensures an Actix service request contains matching CSRF cookie and header values.
261///
262/// This uses [`default_csrf_token_names`]. Prefer [`ensure_service_double_submit_token_with_names`]
263/// in products that configure service-specific token names.
264///
265/// # Errors
266///
267/// Returns [`CsrfError`] when the cookie or header is missing, empty, or does not match.
268pub fn ensure_service_double_submit_token(req: &ServiceRequest) -> Result<()> {
269    ensure_double_submit_token(req.request())
270}
271
272/// Ensures an Actix service request contains matching CSRF cookie and header values using custom
273/// names.
274///
275/// # Errors
276///
277/// Returns [`CsrfError`] when the configured cookie or header is missing, empty, or does not match.
278pub fn ensure_service_double_submit_token_with_names(
279    req: &ServiceRequest,
280    names: &CsrfTokenNames,
281) -> Result<()> {
282    ensure_double_submit_token_with_names(req.request(), names)
283}
284
285/// Validates source headers for an Actix request.
286///
287/// # Errors
288///
289/// Returns [`CsrfError`] when the request origin or source headers are malformed or untrusted.
290pub fn ensure_request_source_allowed(
291    req: &HttpRequest,
292    public_site_origins: &[String],
293    mode: RequestSourceMode,
294) -> Result<()> {
295    let conn = req.connection_info();
296    let request_origin = request_origin(conn.scheme(), conn.host())?;
297    ensure_headers_allowed(
298        header_value(req, header::ORIGIN),
299        header_value(req, header::REFERER),
300        header_value(req, header::HeaderName::from_static("sec-fetch-site")),
301        &request_origin,
302        public_site_origins,
303        mode,
304    )
305}
306
307/// Validates source headers for an Actix service request.
308///
309/// # Errors
310///
311/// Returns [`CsrfError`] when the request origin or source headers are malformed or untrusted.
312pub fn ensure_service_request_source_allowed(
313    req: &ServiceRequest,
314    public_site_origins: &[String],
315    mode: RequestSourceMode,
316) -> Result<()> {
317    let conn = req.connection_info();
318    let request_origin = request_origin(conn.scheme(), conn.host())?;
319    ensure_headers_allowed(
320        header_value(req.request(), header::ORIGIN),
321        header_value(req.request(), header::REFERER),
322        header_value(
323            req.request(),
324            header::HeaderName::from_static("sec-fetch-site"),
325        ),
326        &request_origin,
327        public_site_origins,
328        mode,
329    )
330}
331
332/// Validates raw source header values against the request and public-site origins.
333///
334/// # Errors
335///
336/// Returns [`CsrfError`] when a header is too long, malformed, missing when required, or untrusted.
337pub fn ensure_headers_allowed(
338    origin: Option<&str>,
339    referer: Option<&str>,
340    sec_fetch_site: Option<&str>,
341    request_origin: &str,
342    public_site_origins: &[String],
343    mode: RequestSourceMode,
344) -> Result<()> {
345    let fetch_site = source_header_value(
346        sec_fetch_site,
347        MAX_SEC_FETCH_SITE_LEN,
348        "Sec-Fetch-Site",
349        CsrfErrorKind::RequestHeaderValueInvalid,
350    )?
351    .map(str::to_ascii_lowercase);
352
353    if let Some("cross-site" | "none") = fetch_site.as_deref() {
354        return Err(CsrfError::new(
355            CsrfErrorKind::RequestSourceUntrusted,
356            "untrusted request source for cookie-authenticated action",
357        ));
358    }
359    let same_site_fetch = fetch_site.as_deref() == Some("same-site");
360
361    if let Some(origin) = source_header_value(
362        origin,
363        MAX_SOURCE_HEADER_LEN,
364        "Origin",
365        CsrfErrorKind::RequestOriginInvalid,
366    )?
367    .map(|value| normalize_origin(value, CsrfErrorKind::RequestOriginInvalid))
368    .transpose()?
369    {
370        if origin_is_trusted(&origin, request_origin, public_site_origins) {
371            return Ok(());
372        }
373        return Err(CsrfError::new(
374            CsrfErrorKind::RequestOriginUntrusted,
375            "untrusted request origin for cookie-authenticated action",
376        ));
377    }
378
379    if let Some(referer) = trimmed_header_value(referer) {
380        let referer_origin = origin_from_url(referer)?;
381        if origin_is_trusted(&referer_origin, request_origin, public_site_origins) {
382            return Ok(());
383        }
384        return Err(CsrfError::new(
385            CsrfErrorKind::RequestRefererUntrusted,
386            "untrusted request referer for cookie-authenticated action",
387        ));
388    }
389
390    if same_site_fetch {
391        return Err(CsrfError::new(
392            CsrfErrorKind::RequestSourceUntrusted,
393            "missing trusted request source for same-site cookie-authenticated action",
394        ));
395    }
396
397    match mode {
398        RequestSourceMode::OptionalWhenPresent => Ok(()),
399        RequestSourceMode::Required => Err(CsrfError::new(
400            CsrfErrorKind::RequestSourceMissing,
401            "missing request source for cookie-authenticated action",
402        )),
403    }
404}
405
406fn header_value(req: &HttpRequest, name: header::HeaderName) -> Option<&str> {
407    req.headers()
408        .get(name)
409        .and_then(|value| value.to_str().ok())
410}
411
412fn validate_cookie_name(cookie_name: &str) -> Result<()> {
413    if cookie_name.is_empty() {
414        return Err(CsrfError::new(
415            CsrfErrorKind::TokenNameInvalid,
416            "CSRF cookie name cannot be empty",
417        ));
418    }
419    if cookie_name
420        .bytes()
421        .any(|byte| byte <= 0x20 || byte >= 0x7f || b"()<>@,;:\\\"/[]?={}".contains(&byte))
422    {
423        return Err(CsrfError::new(
424            CsrfErrorKind::TokenNameInvalid,
425            "CSRF cookie name contains invalid characters",
426        ));
427    }
428    Ok(())
429}
430
431fn parse_header_name(header_name: &str) -> Result<HeaderName> {
432    HeaderName::from_bytes(header_name.as_bytes()).map_err(|error| header_name_error(&error))
433}
434
435fn header_name_error(error: &InvalidHeaderName) -> CsrfError {
436    CsrfError::new(
437        CsrfErrorKind::TokenNameInvalid,
438        format!("invalid CSRF header name: {error}"),
439    )
440}
441
442fn request_origin(scheme: &str, host: &str) -> Result<String> {
443    ensure_value_len(
444        scheme,
445        MAX_REQUEST_SCHEME_LEN,
446        "request scheme",
447        CsrfErrorKind::RequestSchemeInvalid,
448    )?;
449    ensure_value_len(
450        host,
451        MAX_REQUEST_HOST_LEN,
452        "request host",
453        CsrfErrorKind::RequestHostInvalid,
454    )?;
455    normalize_origin(
456        &format!("{scheme}://{host}"),
457        CsrfErrorKind::RequestHostInvalid,
458    )
459    .map_err(|_| CsrfError::new(CsrfErrorKind::RequestHostInvalid, "invalid request host"))
460}
461
462fn normalize_origin(origin: &str, kind: CsrfErrorKind) -> Result<String> {
463    aster_forge_utils::url::normalize_origin(origin, false)
464        .map_err(|_| CsrfError::new(kind, "invalid origin"))
465}
466
467fn origin_is_trusted(origin: &str, request_origin: &str, public_site_origins: &[String]) -> bool {
468    origin == request_origin || public_site_origins.iter().any(|allowed| allowed == origin)
469}
470
471fn source_header_value<'a>(
472    value: Option<&'a str>,
473    max_len: usize,
474    label: &str,
475    kind: CsrfErrorKind,
476) -> Result<Option<&'a str>> {
477    let Some(value) = trimmed_header_value(value) else {
478        return Ok(None);
479    };
480    ensure_value_len(value, max_len, label, kind)?;
481    Ok(Some(value))
482}
483
484fn trimmed_header_value(value: Option<&str>) -> Option<&str> {
485    value.map(str::trim).filter(|value| !value.is_empty())
486}
487
488fn ensure_value_len(value: &str, max_len: usize, label: &str, kind: CsrfErrorKind) -> Result<()> {
489    if value.len() > max_len {
490        return Err(CsrfError::new(
491            kind,
492            format!("{label} exceeds {max_len} bytes"),
493        ));
494    }
495    Ok(())
496}
497
498fn origin_from_url(url: &str) -> Result<String> {
499    let scheme_end = url.find("://").ok_or_else(|| {
500        CsrfError::new(
501            CsrfErrorKind::RequestSchemeInvalid,
502            "invalid Referer header",
503        )
504    })?;
505    let scheme = &url[..scheme_end];
506    ensure_value_len(
507        scheme,
508        MAX_REQUEST_SCHEME_LEN,
509        "Referer scheme",
510        CsrfErrorKind::RequestSchemeInvalid,
511    )?;
512
513    let authority_start = scheme_end + 3;
514    let authority_tail = &url[authority_start..];
515    let authority_end = authority_tail
516        .char_indices()
517        .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(authority_start + idx))
518        .unwrap_or(url.len());
519    let authority = &url[authority_start..authority_end];
520    ensure_value_len(
521        authority,
522        MAX_REFERER_AUTHORITY_LEN,
523        "Referer authority",
524        CsrfErrorKind::RequestRefererInvalid,
525    )?;
526
527    normalize_origin(
528        &format!("{}://{}", scheme.to_ascii_lowercase(), authority),
529        CsrfErrorKind::RequestRefererInvalid,
530    )
531    .map_err(|_| {
532        CsrfError::new(
533            CsrfErrorKind::RequestRefererInvalid,
534            "invalid Referer header",
535        )
536    })
537}
538
539#[cfg(test)]
540mod tests {
541    use actix_web::cookie::Cookie;
542
543    use super::{
544        CSRF_COOKIE, CSRF_HEADER, CsrfErrorKind, CsrfTokenNames, RequestSourceMode,
545        build_csrf_token, ensure_double_submit_token, ensure_double_submit_token_with_names,
546        ensure_headers_allowed, ensure_request_source_allowed,
547    };
548
549    fn host_with_len(len: usize) -> String {
550        let suffix = ".example.com";
551        format!("{}{}", "a".repeat(len - suffix.len()), suffix)
552    }
553
554    #[test]
555    fn accepts_same_origin_and_public_site_origin() {
556        assert!(
557            ensure_headers_allowed(
558                Some("http://localhost"),
559                None,
560                Some("same-origin"),
561                "http://localhost",
562                &["https://forge.example.com".to_string()],
563                RequestSourceMode::Required,
564            )
565            .is_ok()
566        );
567
568        assert!(
569            ensure_headers_allowed(
570                Some("https://forge.example.com"),
571                None,
572                Some("same-origin"),
573                "http://127.0.0.1:3000",
574                &["https://forge.example.com".to_string()],
575                RequestSourceMode::Required,
576            )
577            .is_ok()
578        );
579    }
580
581    #[test]
582    fn same_site_fetch_metadata_requires_trusted_origin_or_referer() {
583        assert!(
584            ensure_headers_allowed(
585                Some("https://panel.example.com"),
586                None,
587                Some("same-site"),
588                "https://api.example.com",
589                &[
590                    "https://api.example.com".to_string(),
591                    "https://panel.example.com".to_string(),
592                ],
593                RequestSourceMode::OptionalWhenPresent,
594            )
595            .is_ok()
596        );
597
598        assert!(
599            ensure_headers_allowed(
600                None,
601                Some("https://panel.example.com/settings"),
602                Some("same-site"),
603                "https://api.example.com",
604                &[
605                    "https://api.example.com".to_string(),
606                    "https://panel.example.com".to_string(),
607                ],
608                RequestSourceMode::OptionalWhenPresent,
609            )
610            .is_ok()
611        );
612
613        let err = ensure_headers_allowed(
614            None,
615            None,
616            Some("same-site"),
617            "https://api.example.com",
618            &["https://api.example.com".to_string()],
619            RequestSourceMode::OptionalWhenPresent,
620        )
621        .unwrap_err();
622        assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
623        assert!(err.message().contains("missing trusted request source"));
624    }
625
626    #[test]
627    fn rejects_untrusted_fetch_metadata_values() {
628        for fetch_site in ["cross-site", "none"] {
629            let err = ensure_headers_allowed(
630                None,
631                None,
632                Some(fetch_site),
633                "https://forge.example.com",
634                &[],
635                RequestSourceMode::OptionalWhenPresent,
636            )
637            .unwrap_err();
638            assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
639            assert!(err.message().contains("untrusted request source"));
640        }
641    }
642
643    #[test]
644    fn rejects_untrusted_origin_and_missing_required_source() {
645        let err = ensure_headers_allowed(
646            Some("https://evil.example.com"),
647            None,
648            None,
649            "https://forge.example.com",
650            &[],
651            RequestSourceMode::OptionalWhenPresent,
652        )
653        .unwrap_err();
654        assert_eq!(err.kind(), CsrfErrorKind::RequestOriginUntrusted);
655
656        let err = ensure_headers_allowed(
657            None,
658            None,
659            None,
660            "https://forge.example.com",
661            &[],
662            RequestSourceMode::Required,
663        )
664        .unwrap_err();
665        assert_eq!(err.kind(), CsrfErrorKind::RequestSourceMissing);
666    }
667
668    #[test]
669    fn rejects_oversized_request_source_values_before_normalization() {
670        let max_host = host_with_len(512);
671        let req = actix_web::test::TestRequest::post()
672            .insert_header(("Host", max_host.as_str()))
673            .insert_header(("Origin", format!("http://{max_host}")))
674            .to_http_request();
675        assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
676
677        let long_host = host_with_len(513);
678        let req = actix_web::test::TestRequest::post()
679            .insert_header(("Host", long_host))
680            .insert_header(("Origin", "https://forge.example.com"))
681            .to_http_request();
682        let err =
683            ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
684        assert_eq!(err.kind(), CsrfErrorKind::RequestHostInvalid);
685
686        let req = actix_web::test::TestRequest::post()
687            .insert_header(("Host", "forge.example.com"))
688            .insert_header(("X-Forwarded-Proto", "x".repeat(17)))
689            .insert_header(("Origin", "https://forge.example.com"))
690            .to_http_request();
691        let err =
692            ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
693        assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
694
695        let max_origin = format!("https://{}", host_with_len(2040));
696        assert_eq!(max_origin.len(), 2048);
697        assert!(
698            ensure_headers_allowed(
699                Some(&max_origin),
700                None,
701                None,
702                "https://forge.example.com",
703                std::slice::from_ref(&max_origin),
704                RequestSourceMode::OptionalWhenPresent,
705            )
706            .is_ok()
707        );
708
709        let long_origin = format!("https://{}", host_with_len(2041));
710        assert_eq!(long_origin.len(), 2049);
711        let err = ensure_headers_allowed(
712            Some(&long_origin),
713            None,
714            None,
715            "https://forge.example.com",
716            &[],
717            RequestSourceMode::OptionalWhenPresent,
718        )
719        .unwrap_err();
720        assert_eq!(err.kind(), CsrfErrorKind::RequestOriginInvalid);
721
722        let max_referer_authority = host_with_len(528);
723        let max_referer_origin = format!("https://{max_referer_authority}");
724        let max_referer = format!("{max_referer_origin}/settings");
725        assert!(
726            ensure_headers_allowed(
727                None,
728                Some(&max_referer),
729                None,
730                "https://forge.example.com",
731                &[max_referer_origin],
732                RequestSourceMode::OptionalWhenPresent,
733            )
734            .is_ok()
735        );
736
737        let long_referer_authority = format!("https://{}.example.com/settings", "a".repeat(600));
738        let err = ensure_headers_allowed(
739            None,
740            Some(&long_referer_authority),
741            None,
742            "https://forge.example.com",
743            &[],
744            RequestSourceMode::OptionalWhenPresent,
745        )
746        .unwrap_err();
747        assert_eq!(err.kind(), CsrfErrorKind::RequestRefererInvalid);
748
749        let max_fetch_site = "x".repeat(64);
750        assert!(
751            ensure_headers_allowed(
752                None,
753                None,
754                Some(&max_fetch_site),
755                "https://forge.example.com",
756                &[],
757                RequestSourceMode::OptionalWhenPresent,
758            )
759            .is_ok()
760        );
761
762        let long_fetch_site = "x".repeat(65);
763        let err = ensure_headers_allowed(
764            None,
765            None,
766            Some(&long_fetch_site),
767            "https://forge.example.com",
768            &[],
769            RequestSourceMode::OptionalWhenPresent,
770        )
771        .unwrap_err();
772        assert_eq!(err.kind(), CsrfErrorKind::RequestHeaderValueInvalid);
773    }
774
775    #[test]
776    fn accepts_ipv6_request_host_origin_match() {
777        let req = actix_web::test::TestRequest::post()
778            .insert_header(("Host", "[2001:db8::1]:8443"))
779            .insert_header(("Origin", "http://[2001:db8::1]:8443"))
780            .to_http_request();
781
782        assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
783    }
784
785    #[test]
786    fn referer_source_check_ignores_long_path_after_bounded_origin() {
787        let long_referer = format!("https://forge.example.com/settings/{}", "a".repeat(10_000));
788
789        assert!(
790            ensure_headers_allowed(
791                None,
792                Some(&long_referer),
793                Some("same-origin"),
794                "https://forge.example.com",
795                &[],
796                RequestSourceMode::Required,
797            )
798            .is_ok()
799        );
800    }
801
802    #[test]
803    fn invalid_referer_missing_scheme_reports_invalid_scheme() {
804        let err = ensure_headers_allowed(
805            None,
806            Some("forge.example.com/settings"),
807            None,
808            "https://forge.example.com",
809            &[],
810            RequestSourceMode::OptionalWhenPresent,
811        )
812        .unwrap_err();
813
814        assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
815    }
816
817    #[test]
818    fn accepts_missing_optional_source() {
819        assert!(
820            ensure_headers_allowed(
821                None,
822                None,
823                None,
824                "https://forge.example.com",
825                &[],
826                RequestSourceMode::OptionalWhenPresent,
827            )
828            .is_ok()
829        );
830    }
831
832    #[test]
833    fn build_csrf_token_returns_url_safe_random_value() {
834        let token_a = build_csrf_token();
835        let token_b = build_csrf_token();
836
837        assert_ne!(token_a, token_b);
838        assert!(token_a.len() >= 32);
839        assert!(
840            token_a
841                .chars()
842                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
843        );
844    }
845
846    #[test]
847    fn csrf_token_check_requires_cookie_for_cookie_authenticated_writes() {
848        let req = actix_web::test::TestRequest::post()
849            .uri("/api/v1/auth/profile")
850            .to_http_request();
851
852        let err = ensure_double_submit_token(&req).unwrap_err();
853        assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
854    }
855
856    #[test]
857    fn csrf_token_check_requires_matching_cookie_and_header() {
858        let req = actix_web::test::TestRequest::patch()
859            .uri("/api/v1/auth/profile")
860            .insert_header(("Origin", "http://localhost"))
861            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
862            .insert_header((CSRF_HEADER, "token-a"))
863            .to_http_request();
864        assert!(ensure_double_submit_token(&req).is_ok());
865
866        let missing_header = actix_web::test::TestRequest::patch()
867            .uri("/api/v1/auth/profile")
868            .insert_header(("Origin", "http://localhost"))
869            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
870            .to_http_request();
871        let err = ensure_double_submit_token(&missing_header).unwrap_err();
872        assert_eq!(err.kind(), CsrfErrorKind::HeaderMissing);
873
874        let mismatch = actix_web::test::TestRequest::patch()
875            .uri("/api/v1/auth/profile")
876            .insert_header(("Origin", "http://localhost"))
877            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
878            .insert_header((CSRF_HEADER, "token-b"))
879            .to_http_request();
880        let err = ensure_double_submit_token(&mismatch).unwrap_err();
881        assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
882    }
883
884    #[test]
885    fn csrf_token_check_rejects_tokens_of_different_lengths() {
886        let req = actix_web::test::TestRequest::patch()
887            .uri("/api/v1/auth/profile")
888            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
889            .insert_header((CSRF_HEADER, "token-a-with-a-longer-value"))
890            .to_http_request();
891        let err = ensure_double_submit_token(&req).unwrap_err();
892        assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
893    }
894
895    #[test]
896    fn csrf_token_check_accepts_custom_cookie_and_header_names() {
897        let names = CsrfTokenNames::new("aster_yggdrasil_csrf", "X-Yggdrasil-CSRF-Token")
898            .expect("custom CSRF token names should be valid");
899        assert_eq!(names.cookie_name(), "aster_yggdrasil_csrf");
900        assert_eq!(names.header_name_str(), "x-yggdrasil-csrf-token");
901
902        let req = actix_web::test::TestRequest::patch()
903            .cookie(Cookie::new("aster_yggdrasil_csrf", "token-a"))
904            .insert_header(("X-Yggdrasil-CSRF-Token", "token-a"))
905            .to_http_request();
906        assert!(ensure_double_submit_token_with_names(&req, &names).is_ok());
907
908        let default_req = actix_web::test::TestRequest::patch()
909            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
910            .insert_header((CSRF_HEADER, "token-a"))
911            .to_http_request();
912        let err = ensure_double_submit_token_with_names(&default_req, &names).unwrap_err();
913        assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
914    }
915
916    #[test]
917    fn csrf_token_names_reject_invalid_cookie_and_header_names() {
918        let err = CsrfTokenNames::new("", "X-CSRF-Token").unwrap_err();
919        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
920
921        let err = CsrfTokenNames::new("aster csrf", "X-CSRF-Token").unwrap_err();
922        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
923
924        let err = CsrfTokenNames::new("aster_csrf", "bad header").unwrap_err();
925        assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
926    }
927}