Skip to main content

aster_forge_middleware/actix/
csrf.rs

1//! Actix request/cookie adapter for the shared CSRF core.
2use crate::shared::csrf::request_origin;
3pub use crate::shared::csrf::{
4    CSRF_COOKIE, CSRF_HEADER, CsrfError, CsrfErrorKind, RequestSourceMode, Result,
5    build_csrf_token, ensure_headers_allowed,
6};
7use actix_web::{HttpRequest, dev::ServiceRequest, http::header};
8use std::sync::OnceLock;
9
10/// CSRF names adapted to Actix's HTTP 0.2 header type. Validation remains in the shared core.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CsrfTokenNames {
13    shared: crate::shared::csrf::CsrfTokenNames,
14    header_name: header::HeaderName,
15}
16
17impl CsrfTokenNames {
18    /// Validates product names and adapts the validated header to Actix.
19    ///
20    /// # Errors
21    /// Returns a classified error for invalid cookie/header names.
22    pub fn new(cookie_name: impl Into<String>, header_name: impl AsRef<str>) -> Result<Self> {
23        let shared = crate::shared::csrf::CsrfTokenNames::new(cookie_name, header_name)?;
24        let header_name = header::HeaderName::from_bytes(shared.header_name_str().as_bytes())
25            .map_err(|error| CsrfError::new(CsrfErrorKind::TokenNameInvalid, error.to_string()))?;
26        Ok(Self {
27            shared,
28            header_name,
29        })
30    }
31    /// Configured cookie name.
32    #[must_use]
33    pub fn cookie_name(&self) -> &str {
34        self.shared.cookie_name()
35    }
36    /// Actix header name, preserving the existing transport API.
37    #[must_use]
38    pub fn header_name(&self) -> &header::HeaderName {
39        &self.header_name
40    }
41    /// Canonical lowercase name for CORS and string-based header APIs.
42    #[must_use]
43    pub fn header_name_str(&self) -> &str {
44        self.shared.header_name_str()
45    }
46}
47
48impl Default for CsrfTokenNames {
49    fn default() -> Self {
50        Self {
51            shared: crate::shared::csrf::CsrfTokenNames::default(),
52            header_name: header::HeaderName::from_static("x-csrf-token"),
53        }
54    }
55}
56
57/// Immutable default names adapted to Actix's header type.
58#[must_use]
59pub fn default_csrf_token_names() -> &'static CsrfTokenNames {
60    static NAMES: OnceLock<CsrfTokenNames> = OnceLock::new();
61    NAMES.get_or_init(CsrfTokenNames::default)
62}
63/// Ensures an Actix request contains matching CSRF cookie and header values.
64///
65/// This uses [`default_csrf_token_names`]. Prefer [`ensure_double_submit_token_with_names`] in
66/// products that can run beside another Aster service on the same browser origin.
67///
68/// # Errors
69///
70/// Returns [`CsrfError`] when the cookie or header is missing, empty, or does not match.
71pub fn ensure_double_submit_token(req: &HttpRequest) -> Result<()> {
72    ensure_double_submit_token_with_names(req, default_csrf_token_names())
73}
74
75/// Ensures an Actix request contains matching CSRF cookie and header values using custom names.
76///
77/// The helper only performs the double-submit comparison. Product middleware should decide when to
78/// call it, usually for unsafe methods authenticated by cookies. Pair it with request-source
79/// validation to reject cross-site writes before checking the token value.
80///
81/// # Errors
82///
83/// Returns [`CsrfError`] when the configured cookie or header is missing, empty, or does not match.
84pub fn ensure_double_submit_token_with_names(
85    req: &HttpRequest,
86    names: &CsrfTokenNames,
87) -> Result<()> {
88    let cookie = req.cookie(names.cookie_name());
89    crate::shared::csrf::ensure_token_values(
90        cookie.as_ref().map(actix_web::cookie::Cookie::value),
91        req.headers()
92            .get(names.header_name_str())
93            .and_then(|value| value.to_str().ok()),
94    )
95}
96
97/// Ensures an Actix service request contains matching CSRF cookie and header values.
98///
99/// This uses [`default_csrf_token_names`]. Prefer [`ensure_service_double_submit_token_with_names`]
100/// in products that configure service-specific token names.
101///
102/// # Errors
103///
104/// Returns [`CsrfError`] when the cookie or header is missing, empty, or does not match.
105pub fn ensure_service_double_submit_token(req: &ServiceRequest) -> Result<()> {
106    ensure_double_submit_token(req.request())
107}
108
109/// Ensures an Actix service request contains matching CSRF cookie and header values using custom
110/// names.
111///
112/// # Errors
113///
114/// Returns [`CsrfError`] when the configured cookie or header is missing, empty, or does not match.
115pub fn ensure_service_double_submit_token_with_names(
116    req: &ServiceRequest,
117    names: &CsrfTokenNames,
118) -> Result<()> {
119    ensure_double_submit_token_with_names(req.request(), names)
120}
121
122/// Validates source headers for an Actix request.
123///
124/// # Errors
125///
126/// Returns [`CsrfError`] when the request origin or source headers are malformed or untrusted.
127pub fn ensure_request_source_allowed(
128    req: &HttpRequest,
129    public_site_origins: &[String],
130    mode: RequestSourceMode,
131) -> Result<()> {
132    let conn = req.connection_info();
133    let request_origin = request_origin(conn.scheme(), conn.host())?;
134    ensure_headers_allowed(
135        header_value(req, header::ORIGIN),
136        header_value(req, header::REFERER),
137        header_value(req, header::HeaderName::from_static("sec-fetch-site")),
138        &request_origin,
139        public_site_origins,
140        mode,
141    )
142}
143
144/// Validates source headers for an Actix service request.
145///
146/// # Errors
147///
148/// Returns [`CsrfError`] when the request origin or source headers are malformed or untrusted.
149pub fn ensure_service_request_source_allowed(
150    req: &ServiceRequest,
151    public_site_origins: &[String],
152    mode: RequestSourceMode,
153) -> Result<()> {
154    let conn = req.connection_info();
155    let request_origin = request_origin(conn.scheme(), conn.host())?;
156    ensure_headers_allowed(
157        header_value(req.request(), header::ORIGIN),
158        header_value(req.request(), header::REFERER),
159        header_value(
160            req.request(),
161            header::HeaderName::from_static("sec-fetch-site"),
162        ),
163        &request_origin,
164        public_site_origins,
165        mode,
166    )
167}
168
169/// Whether an Actix method needs CSRF protection.
170#[must_use]
171pub fn is_unsafe_method(method: &actix_web::http::Method) -> bool {
172    !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE")
173}
174fn header_value(req: &HttpRequest, name: header::HeaderName) -> Option<&str> {
175    req.headers()
176        .get(name)
177        .map(|value| value.to_str().unwrap_or("\u{fffd}"))
178}
179
180#[cfg(test)]
181mod tests {
182    use actix_web::cookie::Cookie;
183
184    use super::{
185        CSRF_COOKIE, CSRF_HEADER, CsrfErrorKind, CsrfTokenNames, RequestSourceMode,
186        ensure_double_submit_token, ensure_double_submit_token_with_names, ensure_headers_allowed,
187        ensure_request_source_allowed,
188    };
189
190    fn host_with_len(len: usize) -> String {
191        let suffix = ".example.com";
192        format!("{}{}", "a".repeat(len - suffix.len()), suffix)
193    }
194
195    #[test]
196    fn configured_header_name_remains_usable_with_actix_header_apis() {
197        let names = CsrfTokenNames::new("custom_csrf", "X-Custom-CSRF").unwrap();
198        let req = actix_web::test::TestRequest::post()
199            .cookie(Cookie::new("custom_csrf", "token"))
200            .insert_header((names.header_name(), "token"))
201            .to_http_request();
202        assert!(ensure_double_submit_token_with_names(&req, &names).is_ok());
203    }
204
205    #[test]
206    fn opaque_source_headers_are_rejected_in_optional_mode() {
207        for (name, expected) in [
208            ("origin", CsrfErrorKind::RequestOriginInvalid),
209            ("referer", CsrfErrorKind::RequestSchemeInvalid),
210            ("sec-fetch-site", CsrfErrorKind::RequestHeaderValueInvalid),
211        ] {
212            let req = actix_web::test::TestRequest::post()
213                .insert_header((
214                    name,
215                    actix_web::http::header::HeaderValue::from_bytes(&[0xff]).unwrap(),
216                ))
217                .to_http_request();
218            assert_eq!(
219                ensure_request_source_allowed(&req, &[], RequestSourceMode::OptionalWhenPresent)
220                    .unwrap_err()
221                    .kind(),
222                expected
223            );
224        }
225    }
226
227    #[test]
228    fn rejects_oversized_request_source_values_before_normalization() {
229        let max_host = host_with_len(512);
230        let req = actix_web::test::TestRequest::post()
231            .insert_header(("Host", max_host.as_str()))
232            .insert_header(("Origin", format!("http://{max_host}")))
233            .to_http_request();
234        assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
235
236        let long_host = host_with_len(513);
237        let req = actix_web::test::TestRequest::post()
238            .insert_header(("Host", long_host))
239            .insert_header(("Origin", "https://forge.example.com"))
240            .to_http_request();
241        let err =
242            ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
243        assert_eq!(err.kind(), CsrfErrorKind::RequestHostInvalid);
244
245        let req = actix_web::test::TestRequest::post()
246            .insert_header(("Host", "forge.example.com"))
247            .insert_header(("X-Forwarded-Proto", "x".repeat(17)))
248            .insert_header(("Origin", "https://forge.example.com"))
249            .to_http_request();
250        let err =
251            ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
252        assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
253
254        let max_origin = format!("https://{}", host_with_len(2040));
255        assert_eq!(max_origin.len(), 2048);
256        assert!(
257            ensure_headers_allowed(
258                Some(&max_origin),
259                None,
260                None,
261                "https://forge.example.com",
262                std::slice::from_ref(&max_origin),
263                RequestSourceMode::OptionalWhenPresent,
264            )
265            .is_ok()
266        );
267
268        let long_origin = format!("https://{}", host_with_len(2041));
269        assert_eq!(long_origin.len(), 2049);
270        let err = ensure_headers_allowed(
271            Some(&long_origin),
272            None,
273            None,
274            "https://forge.example.com",
275            &[],
276            RequestSourceMode::OptionalWhenPresent,
277        )
278        .unwrap_err();
279        assert_eq!(err.kind(), CsrfErrorKind::RequestOriginInvalid);
280
281        let max_referer_authority = host_with_len(528);
282        let max_referer_origin = format!("https://{max_referer_authority}");
283        let max_referer = format!("{max_referer_origin}/settings");
284        assert!(
285            ensure_headers_allowed(
286                None,
287                Some(&max_referer),
288                None,
289                "https://forge.example.com",
290                &[max_referer_origin],
291                RequestSourceMode::OptionalWhenPresent,
292            )
293            .is_ok()
294        );
295
296        let long_referer_authority = format!("https://{}.example.com/settings", "a".repeat(600));
297        let err = ensure_headers_allowed(
298            None,
299            Some(&long_referer_authority),
300            None,
301            "https://forge.example.com",
302            &[],
303            RequestSourceMode::OptionalWhenPresent,
304        )
305        .unwrap_err();
306        assert_eq!(err.kind(), CsrfErrorKind::RequestRefererInvalid);
307
308        let max_fetch_site = "x".repeat(64);
309        assert!(
310            ensure_headers_allowed(
311                None,
312                None,
313                Some(&max_fetch_site),
314                "https://forge.example.com",
315                &[],
316                RequestSourceMode::OptionalWhenPresent,
317            )
318            .is_ok()
319        );
320
321        let long_fetch_site = "x".repeat(65);
322        let err = ensure_headers_allowed(
323            None,
324            None,
325            Some(&long_fetch_site),
326            "https://forge.example.com",
327            &[],
328            RequestSourceMode::OptionalWhenPresent,
329        )
330        .unwrap_err();
331        assert_eq!(err.kind(), CsrfErrorKind::RequestHeaderValueInvalid);
332    }
333
334    #[test]
335    fn accepts_ipv6_request_host_origin_match() {
336        let req = actix_web::test::TestRequest::post()
337            .insert_header(("Host", "[2001:db8::1]:8443"))
338            .insert_header(("Origin", "http://[2001:db8::1]:8443"))
339            .to_http_request();
340
341        assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
342    }
343
344    #[test]
345    fn csrf_token_check_requires_cookie_for_cookie_authenticated_writes() {
346        let req = actix_web::test::TestRequest::post()
347            .uri("/api/v1/auth/profile")
348            .to_http_request();
349
350        let err = ensure_double_submit_token(&req).unwrap_err();
351        assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
352    }
353
354    #[test]
355    fn csrf_token_check_requires_matching_cookie_and_header() {
356        let req = actix_web::test::TestRequest::patch()
357            .uri("/api/v1/auth/profile")
358            .insert_header(("Origin", "http://localhost"))
359            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
360            .insert_header((CSRF_HEADER, "token-a"))
361            .to_http_request();
362        assert!(ensure_double_submit_token(&req).is_ok());
363
364        let missing_header = actix_web::test::TestRequest::patch()
365            .uri("/api/v1/auth/profile")
366            .insert_header(("Origin", "http://localhost"))
367            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
368            .to_http_request();
369        let err = ensure_double_submit_token(&missing_header).unwrap_err();
370        assert_eq!(err.kind(), CsrfErrorKind::HeaderMissing);
371
372        let mismatch = actix_web::test::TestRequest::patch()
373            .uri("/api/v1/auth/profile")
374            .insert_header(("Origin", "http://localhost"))
375            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
376            .insert_header((CSRF_HEADER, "token-b"))
377            .to_http_request();
378        let err = ensure_double_submit_token(&mismatch).unwrap_err();
379        assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
380    }
381
382    #[test]
383    fn csrf_token_check_rejects_tokens_of_different_lengths() {
384        let req = actix_web::test::TestRequest::patch()
385            .uri("/api/v1/auth/profile")
386            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
387            .insert_header((CSRF_HEADER, "token-a-with-a-longer-value"))
388            .to_http_request();
389        let err = ensure_double_submit_token(&req).unwrap_err();
390        assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
391    }
392
393    #[test]
394    fn csrf_token_check_accepts_custom_cookie_and_header_names() {
395        let names = CsrfTokenNames::new("aster_yggdrasil_csrf", "X-Yggdrasil-CSRF-Token")
396            .expect("custom CSRF token names should be valid");
397        assert_eq!(names.cookie_name(), "aster_yggdrasil_csrf");
398        assert_eq!(names.header_name_str(), "x-yggdrasil-csrf-token");
399
400        let req = actix_web::test::TestRequest::patch()
401            .cookie(Cookie::new("aster_yggdrasil_csrf", "token-a"))
402            .insert_header(("X-Yggdrasil-CSRF-Token", "token-a"))
403            .to_http_request();
404        assert!(ensure_double_submit_token_with_names(&req, &names).is_ok());
405
406        let default_req = actix_web::test::TestRequest::patch()
407            .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
408            .insert_header((CSRF_HEADER, "token-a"))
409            .to_http_request();
410        let err = ensure_double_submit_token_with_names(&default_req, &names).unwrap_err();
411        assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
412    }
413}