Skip to main content

aster_forge_middleware/axum/
csrf.rs

1//! Axum CSRF adapter with explicit product route, source and error boundaries.
2pub use crate::shared::csrf::{CsrfError, CsrfErrorKind, CsrfTokenNames, RequestSourceMode};
3use axum::{
4    extract::{Request, State},
5    middleware::Next,
6    response::Response,
7};
8use http::{HeaderMap, header};
9use std::sync::Arc;
10
11/// Runtime source policy supplied by product configuration.
12#[derive(Clone)]
13pub struct RequestSourcePolicy {
14    /// Trusted canonical request scheme (e.g. configured HTTPS), not raw forwarded headers.
15    pub scheme: String,
16    /// Trusted canonical host/authority; product validates proxy provenance before using it.
17    pub host: String,
18    /// Already-normalized public site origins.
19    pub public_site_origins: Vec<String>,
20    /// Whether a trusted Origin/Referer is mandatory.
21    pub mode: RequestSourceMode,
22}
23
24/// Checks decoded cookies and headers using the shared constant-time comparison.
25///
26/// # Errors
27/// Returns a classified error for missing, empty or mismatched tokens.
28pub fn ensure_double_submit_token_with_names(
29    headers: &HeaderMap,
30    names: &CsrfTokenNames,
31) -> crate::shared::csrf::Result<()> {
32    let cookie = headers
33        .get_all(header::COOKIE)
34        .iter()
35        .filter_map(|value| value.to_str().ok())
36        .flat_map(|value| value.split(';'))
37        .filter_map(|value| cookie::Cookie::parse_encoded(value.trim()).ok())
38        .find(|cookie| cookie.name() == names.cookie_name());
39    crate::shared::csrf::ensure_token_values(
40        cookie.as_ref().map(cookie::Cookie::value),
41        headers
42            .get(names.header_name())
43            .and_then(|value| value.to_str().ok()),
44    )
45}
46
47/// Checks tokens using the default names.
48///
49/// # Errors
50/// Returns a classified error for missing, empty or mismatched tokens.
51pub fn ensure_double_submit_token(headers: &HeaderMap) -> crate::shared::csrf::Result<()> {
52    ensure_double_submit_token_with_names(headers, crate::shared::csrf::default_csrf_token_names())
53}
54
55/// Checks request source headers against explicit trusted scheme/host and public origins.
56/// Opaque non-UTF8 headers are invalid, including in optional mode.
57///
58/// # Errors
59/// Returns a classified error for malformed, missing or untrusted source values.
60pub fn ensure_request_source_allowed(
61    headers: &HeaderMap,
62    policy: &RequestSourcePolicy,
63) -> crate::shared::csrf::Result<()> {
64    let origin = crate::shared::csrf::request_origin(&policy.scheme, &policy.host)?;
65    let value = |name| {
66        headers
67            .get(name)
68            .map(|value| value.to_str().unwrap_or("\u{fffd}"))
69    };
70    crate::shared::csrf::ensure_headers_allowed(
71        value("origin"),
72        value("referer"),
73        value("sec-fetch-site"),
74        &origin,
75        &policy.public_site_origins,
76        policy.mode,
77    )
78}
79
80type Predicate = dyn Fn(&Request) -> bool + Send + Sync;
81type SourceResolver = dyn Fn(&Request) -> Result<RequestSourcePolicy, Box<Response>> + Send + Sync;
82type ErrorMapper = dyn Fn(CsrfError) -> Response + Send + Sync;
83
84/// State for `from_fn_with_state(config, csrf)`. Product predicates decide cookie authentication.
85#[derive(Clone)]
86pub struct CsrfConfig {
87    names: CsrfTokenNames,
88    protect: Arc<Predicate>,
89    source: Arc<SourceResolver>,
90    map_error: Arc<ErrorMapper>,
91}
92
93impl CsrfConfig {
94    /// Builds middleware with explicit product callbacks; safe methods always bypass protection.
95    pub fn new<P, S, M>(names: CsrfTokenNames, protect: P, source: S, map_error: M) -> Self
96    where
97        P: Fn(&Request) -> bool + Send + Sync + 'static,
98        S: Fn(&Request) -> Result<RequestSourcePolicy, Box<Response>> + Send + Sync + 'static,
99        M: Fn(CsrfError) -> Response + Send + Sync + 'static,
100    {
101        Self {
102            names,
103            protect: Arc::new(protect),
104            source: Arc::new(source),
105            map_error: Arc::new(map_error),
106        }
107    }
108}
109
110/// Enforces source and double-submit checks on product-selected unsafe requests.
111pub async fn csrf(State(config): State<CsrfConfig>, request: Request, next: Next) -> Response {
112    if crate::shared::csrf::is_unsafe_method(request.method()) && (config.protect)(&request) {
113        let policy = match (config.source)(&request) {
114            Ok(policy) => policy,
115            Err(response) => return *response,
116        };
117        let result = ensure_request_source_allowed(request.headers(), &policy)
118            .and_then(|()| ensure_double_submit_token_with_names(request.headers(), &config.names));
119        if let Err(error) = result {
120            return (config.map_error)(error);
121        }
122    }
123    next.run(request).await
124}
125
126#[cfg(test)]
127mod tests {
128
129    use super::{CsrfConfig, CsrfTokenNames, RequestSourceMode, RequestSourcePolicy, csrf};
130    use axum::{
131        Router,
132        body::{Body, to_bytes},
133        middleware,
134        response::IntoResponse,
135        routing::any,
136    };
137    use http::{HeaderValue, Request, StatusCode};
138    use tower::ServiceExt;
139
140    fn policy() -> RequestSourcePolicy {
141        RequestSourcePolicy {
142            scheme: "https".into(),
143            host: "api.example.com".into(),
144            public_site_origins: vec!["https://panel.example.com".into()],
145            mode: RequestSourceMode::Required,
146        }
147    }
148
149    #[tokio::test]
150    async fn product_source_resolver_failure_is_returned_unchanged() {
151        let config = CsrfConfig::new(
152            CsrfTokenNames::default(),
153            |_| true,
154            |_| {
155                Err(Box::new(
156                    (StatusCode::SERVICE_UNAVAILABLE, "source policy unavailable").into_response(),
157                ))
158            },
159            |_| StatusCode::FORBIDDEN.into_response(),
160        );
161        let router = Router::new()
162            .fallback(any(|| async { "called" }))
163            .layer(middleware::from_fn_with_state(config, csrf));
164        assert_eq!(
165            result(
166                router,
167                Request::builder()
168                    .method("POST")
169                    .body(Body::empty())
170                    .unwrap()
171            )
172            .await,
173            (
174                StatusCode::SERVICE_UNAVAILABLE,
175                "source policy unavailable".into()
176            )
177        );
178    }
179
180    fn app(names: CsrfTokenNames, source: RequestSourcePolicy) -> Router {
181        let config = CsrfConfig::new(
182            names,
183            |req| req.uri().path() != "/public",
184            move |_| Ok(source.clone()),
185            |error| (StatusCode::FORBIDDEN, format!("{:?}", error.kind())).into_response(),
186        );
187        Router::new()
188            .fallback(any(|| async { "called" }))
189            .layer(middleware::from_fn_with_state(config, csrf))
190    }
191
192    async fn result(app: Router, request: Request<Body>) -> (StatusCode, String) {
193        let response = app.oneshot(request).await.unwrap();
194        let status = response.status();
195        let body = to_bytes(response.into_body(), 4096).await.unwrap();
196        (status, String::from_utf8(body.to_vec()).unwrap())
197    }
198
199    #[tokio::test]
200    async fn safe_methods_and_product_exempt_routes_bypass_checks() {
201        for method in ["GET", "HEAD", "OPTIONS", "TRACE"] {
202            let (status, _) = result(
203                app(CsrfTokenNames::default(), policy()),
204                Request::builder()
205                    .method(method)
206                    .uri("/")
207                    .body(Body::empty())
208                    .unwrap(),
209            )
210            .await;
211            assert_eq!(status, StatusCode::OK, "{method}");
212        }
213        for method in ["POST", "PATCH", "PUT", "DELETE"] {
214            let router = app(CsrfTokenNames::default(), policy());
215            let (status, body) = result(
216                router.clone(),
217                Request::builder()
218                    .method(method)
219                    .body(Body::empty())
220                    .unwrap(),
221            )
222            .await;
223            assert_eq!(
224                (status, body.as_str()),
225                (StatusCode::FORBIDDEN, "RequestSourceMissing")
226            );
227            let (status, _) = result(
228                router,
229                Request::builder()
230                    .method(method)
231                    .uri("/public")
232                    .body(Body::empty())
233                    .unwrap(),
234            )
235            .await;
236            assert_eq!(status, StatusCode::OK);
237        }
238    }
239
240    #[tokio::test]
241    async fn token_failures_are_mapped_before_the_handler_runs() {
242        for (cookie, header, kind) in [
243            (None, None, "CookieMissing"),
244            (Some("aster_csrf=token"), None, "HeaderMissing"),
245            (Some("aster_csrf=token"), Some(""), "HeaderMissing"),
246            (Some("aster_csrf=token"), Some("  "), "HeaderMissing"),
247            (Some("aster_csrf="), Some("token"), "TokenInvalid"),
248            (Some("aster_csrf=token"), Some("wrong"), "TokenInvalid"),
249            (
250                Some("aster_csrf=token"),
251                Some("longer-token"),
252                "TokenInvalid",
253            ),
254            (Some("aster_csrf=token"), Some(" token "), "called"),
255        ] {
256            let mut req = Request::builder()
257                .method("POST")
258                .header("origin", "https://api.example.com");
259            if let Some(cookie) = cookie {
260                req = req.header("cookie", cookie);
261            }
262            if let Some(header) = header {
263                req = req.header("x-csrf-token", header);
264            }
265            let (status, body) = result(
266                app(CsrfTokenNames::default(), policy()),
267                req.body(Body::empty()).unwrap(),
268            )
269            .await;
270            assert_eq!(body, kind);
271            assert_eq!(
272                status,
273                if kind == "called" {
274                    StatusCode::OK
275                } else {
276                    StatusCode::FORBIDDEN
277                }
278            );
279        }
280    }
281
282    #[tokio::test]
283    async fn custom_names_and_encoded_multi_cookie_headers_work() {
284        let names = CsrfTokenNames::new("product_csrf", "X-Product-CSRF").unwrap();
285        let router = app(names, policy());
286        let request = Request::builder()
287            .method("PATCH")
288            .header("origin", "https://panel.example.com")
289            .header("cookie", "other=value; invalid")
290            .header("cookie", "product_csrf=token%2Da")
291            .header("X-Product-CSRF", "token-a")
292            .body(Body::empty())
293            .unwrap();
294        assert_eq!(
295            result(router.clone(), request).await,
296            (StatusCode::OK, "called".into())
297        );
298        let request = Request::builder()
299            .method("PATCH")
300            .header("origin", "https://api.example.com")
301            .header("cookie", "aster_csrf=token-a")
302            .header("X-CSRF-Token", "token-a")
303            .body(Body::empty())
304            .unwrap();
305        assert_eq!(result(router, request).await.1, "CookieMissing");
306    }
307
308    #[tokio::test]
309    async fn source_origin_referer_priority_and_fetch_metadata_match_actix() {
310        for (origin, referer, fetch, expected) in [
311            (
312                Some("https://api.example.com"),
313                None,
314                Some("same-origin"),
315                "called",
316            ),
317            (
318                Some("https://panel.example.com"),
319                None,
320                Some("same-site"),
321                "called",
322            ),
323            (
324                None,
325                Some("https://panel.example.com/settings"),
326                Some("same-site"),
327                "called",
328            ),
329            (
330                Some("https://evil.example.com"),
331                Some("https://api.example.com/"),
332                None,
333                "RequestOriginUntrusted",
334            ),
335            (
336                Some("https://api.example.com"),
337                Some("https://evil.example.com/"),
338                None,
339                "called",
340            ),
341            (
342                None,
343                Some("https://evil.example.com/"),
344                None,
345                "RequestRefererUntrusted",
346            ),
347            (Some("null"), None, None, "RequestOriginInvalid"),
348            (
349                None,
350                Some("missing-scheme/path"),
351                None,
352                "RequestSchemeInvalid",
353            ),
354            (None, None, Some("same-site"), "RequestSourceUntrusted"),
355            (
356                Some("https://api.example.com"),
357                None,
358                Some("cross-site"),
359                "RequestSourceUntrusted",
360            ),
361            (
362                Some("https://api.example.com"),
363                None,
364                Some("none"),
365                "RequestSourceUntrusted",
366            ),
367            (None, None, None, "RequestSourceMissing"),
368        ] {
369            let mut req = Request::builder()
370                .method("POST")
371                .header("cookie", "aster_csrf=t")
372                .header("x-csrf-token", "t");
373            if let Some(origin) = origin {
374                req = req.header("origin", origin);
375            }
376            if let Some(referer) = referer {
377                req = req.header("referer", referer);
378            }
379            if let Some(fetch) = fetch {
380                req = req.header("sec-fetch-site", fetch);
381            }
382            assert_eq!(
383                result(
384                    app(CsrfTokenNames::default(), policy()),
385                    req.body(Body::empty()).unwrap()
386                )
387                .await
388                .1,
389                expected
390            );
391        }
392    }
393
394    #[tokio::test]
395    async fn optional_mode_still_rejects_opaque_and_oversized_headers() {
396        let mut source = policy();
397        source.mode = RequestSourceMode::OptionalWhenPresent;
398        let router = app(CsrfTokenNames::default(), source);
399        for (name, value, expected) in [
400            (
401                "origin",
402                HeaderValue::from_bytes(&[0xff]).unwrap(),
403                "RequestOriginInvalid",
404            ),
405            (
406                "referer",
407                HeaderValue::from_bytes(&[0xff]).unwrap(),
408                "RequestSchemeInvalid",
409            ),
410            (
411                "sec-fetch-site",
412                HeaderValue::from_bytes(&[0xff]).unwrap(),
413                "RequestHeaderValueInvalid",
414            ),
415            (
416                "origin",
417                HeaderValue::from_str(&format!("https://{}.com", "a".repeat(2048))).unwrap(),
418                "RequestOriginInvalid",
419            ),
420            (
421                "referer",
422                HeaderValue::from_str(&format!("https://{}.com/", "a".repeat(529))).unwrap(),
423                "RequestRefererInvalid",
424            ),
425            (
426                "sec-fetch-site",
427                HeaderValue::from_str(&"a".repeat(65)).unwrap(),
428                "RequestHeaderValueInvalid",
429            ),
430        ] {
431            let req = Request::builder()
432                .method("POST")
433                .header(name, value)
434                .header("cookie", "aster_csrf=t")
435                .header("x-csrf-token", "t")
436                .body(Body::empty())
437                .unwrap();
438            assert_eq!(result(router.clone(), req).await.1, expected);
439        }
440        let req = Request::builder()
441            .method("POST")
442            .header("cookie", "aster_csrf=t")
443            .header("x-csrf-token", "t")
444            .body(Body::empty())
445            .unwrap();
446        assert_eq!(result(router, req).await.0, StatusCode::OK);
447    }
448
449    #[tokio::test]
450    async fn trusted_scheme_host_are_bounded_and_cannot_be_forged_by_headers() {
451        let mut source = policy();
452        source.scheme = "x".repeat(17);
453        let req = || {
454            Request::builder()
455                .method("POST")
456                .header("origin", "https://api.example.com")
457                .header("cookie", "aster_csrf=t")
458                .header("x-csrf-token", "t")
459                .body(Body::empty())
460                .unwrap()
461        };
462        assert_eq!(
463            result(app(CsrfTokenNames::default(), source), req())
464                .await
465                .1,
466            "RequestSchemeInvalid"
467        );
468        let mut source = policy();
469        source.host = "a".repeat(513);
470        assert_eq!(
471            result(app(CsrfTokenNames::default(), source), req())
472                .await
473                .1,
474            "RequestHostInvalid"
475        );
476        let req = Request::builder()
477            .method("POST")
478            .header("origin", "https://evil.example.com")
479            .header("host", "evil.example.com")
480            .header("x-forwarded-proto", "https")
481            .header("x-forwarded-host", "evil.example.com")
482            .header("cookie", "aster_csrf=t")
483            .header("x-csrf-token", "t")
484            .body(Body::empty())
485            .unwrap();
486        assert_eq!(
487            result(app(CsrfTokenNames::default(), policy()), req)
488                .await
489                .1,
490            "RequestOriginUntrusted"
491        );
492    }
493}