| 403 |
403 |
|
posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Skip(reason))
|
| 404 |
404 |
|
}
|
| 405 |
405 |
|
|
|
406 |
+ |
// --- Origin gate: posture-independent pre-auth seal ----------------------
|
|
407 |
+ |
//
|
|
408 |
+ |
// Applied once to the whole `CsrfRouter` tree in `finalize`, so it covers
|
|
409 |
+ |
// every registered route — Auto, Manual, and Skip alike — and runs before any
|
|
410 |
+ |
// per-route posture. It closes the pre-auth forgery vector (CHRONIC A'): the
|
|
411 |
+ |
// per-route `validate_auto` deliberately does NOT require a token from a
|
|
412 |
+ |
// logged-out caller (the public form path relies on the anonymous-session
|
|
413 |
+ |
// token), so without this gate a cross-site forged POST to a public form such
|
|
414 |
+ |
// as `/forgot-password` would reach the handler.
|
|
415 |
+ |
//
|
|
416 |
+ |
// Policy: reject only when the request is *positively* identified as
|
|
417 |
+ |
// cross-site. A request carrying no origin signal at all is allowed through —
|
|
418 |
+ |
// every modern browser sends `Sec-Fetch-Site`, so the no-signal case is
|
|
419 |
+ |
// non-browser traffic (Stripe webhooks, OAuth callbacks, mnw-cli, curl) that
|
|
420 |
+ |
// cannot be driven cross-site from a victim's browser. This keeps server-to-
|
|
421 |
+ |
// server and CLI clients working while sealing the browser forgery path.
|
|
422 |
+ |
|
|
423 |
+ |
fn is_mutating(method: &axum::http::Method) -> bool {
|
|
424 |
+ |
matches!(
|
|
425 |
+ |
*method,
|
|
426 |
+ |
axum::http::Method::POST
|
|
427 |
+ |
| axum::http::Method::PUT
|
|
428 |
+ |
| axum::http::Method::PATCH
|
|
429 |
+ |
| axum::http::Method::DELETE
|
|
430 |
+ |
)
|
|
431 |
+ |
}
|
|
432 |
+ |
|
|
433 |
+ |
/// Host (no scheme, no port, lowercased) from an `Origin`/`Referer` value.
|
|
434 |
+ |
/// `None` for opaque origins (`"null"`), scheme-less values, or anything
|
|
435 |
+ |
/// unparseable — callers treat `None` as "no usable signal" (allow).
|
|
436 |
+ |
fn url_host(value: &str) -> Option<String> {
|
|
437 |
+ |
let rest = value
|
|
438 |
+ |
.strip_prefix("https://")
|
|
439 |
+ |
.or_else(|| value.strip_prefix("http://"))?;
|
|
440 |
+ |
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
|
|
441 |
+ |
// Drop any userinfo (defensive; Origin never carries it).
|
|
442 |
+ |
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
|
443 |
+ |
Some(strip_port(authority)).filter(|h| !h.is_empty())
|
|
444 |
+ |
}
|
|
445 |
+ |
|
|
446 |
+ |
/// The request's own host from the `Host` header, port stripped, lowercased.
|
|
447 |
+ |
fn request_host(headers: &axum::http::HeaderMap) -> Option<String> {
|
|
448 |
+ |
let raw = headers
|
|
449 |
+ |
.get(axum::http::header::HOST)?
|
|
450 |
+ |
.to_str()
|
|
451 |
+ |
.ok()?;
|
|
452 |
+ |
Some(strip_port(raw)).filter(|h| !h.is_empty())
|
|
453 |
+ |
}
|
|
454 |
+ |
|
|
455 |
+ |
/// Strip a trailing `:port`, handle bracketed IPv6 literals, and lowercase.
|
|
456 |
+ |
fn strip_port(authority: &str) -> String {
|
|
457 |
+ |
let host = if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) {
|
|
458 |
+ |
// `[::1]:8080` -> `[::1]`
|
|
459 |
+ |
&authority[..end + 2]
|
|
460 |
+ |
} else {
|
|
461 |
+ |
authority.split(':').next().unwrap_or(authority)
|
|
462 |
+ |
};
|
|
463 |
+ |
host.to_ascii_lowercase()
|
|
464 |
+ |
}
|
|
465 |
+ |
|
|
466 |
+ |
/// Returns true only when the request is *positively* identified as cross-site.
|
|
467 |
+ |
/// Absent or ambiguous signals return false (allow). See [`origin_gate`].
|
|
468 |
+ |
fn is_cross_site(headers: &axum::http::HeaderMap) -> bool {
|
|
469 |
+ |
// 1. Sec-Fetch-Site (sent by every modern browser) is authoritative.
|
|
470 |
+ |
if let Some(sfs) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
|
|
471 |
+ |
// same-origin | same-site | none (user-initiated) => not cross-site.
|
|
472 |
+ |
return sfs.eq_ignore_ascii_case("cross-site");
|
|
473 |
+ |
}
|
|
474 |
+ |
// 2. Header-less clients: a present Origin/Referer host must match Host.
|
|
475 |
+ |
// No Host to compare against, or no usable origin host => allow.
|
|
476 |
+ |
let Some(host) = request_host(headers) else {
|
|
477 |
+ |
return false;
|
|
478 |
+ |
};
|
|
479 |
+ |
if let Some(origin) = headers.get(axum::http::header::ORIGIN).and_then(|v| v.to_str().ok()) {
|
|
480 |
+ |
return url_host(origin).is_some_and(|h| h != host);
|
|
481 |
+ |
}
|
|
482 |
+ |
if let Some(referer) = headers.get(axum::http::header::REFERER).and_then(|v| v.to_str().ok()) {
|
|
483 |
+ |
return url_host(referer).is_some_and(|h| h != host);
|
|
484 |
+ |
}
|
|
485 |
+ |
false
|
|
486 |
+ |
}
|
|
487 |
+ |
|
|
488 |
+ |
/// Posture-independent origin gate; see the module section comment above.
|
|
489 |
+ |
async fn origin_gate(request: Request, next: Next) -> Response {
|
|
490 |
+ |
if is_mutating(request.method()) && is_cross_site(request.headers()) {
|
|
491 |
+ |
tracing::warn!(
|
|
492 |
+ |
path = %request.uri().path(),
|
|
493 |
+ |
"CSRF origin gate: cross-site mutation rejected"
|
|
494 |
+ |
);
|
|
495 |
+ |
return crate::error::AppError::Forbidden.into_response();
|
|
496 |
+ |
}
|
|
497 |
+ |
next.run(request).await
|
|
498 |
+ |
}
|
|
499 |
+ |
|
| 406 |
500 |
|
// --- CsrfRouter: structural enforcement ----------------------------------
|
| 407 |
501 |
|
//
|
| 408 |
502 |
|
// `CsrfRouter` is the only way to register a mutation route in this
|
| 486 |
580 |
|
/// Called once in `build_app` after all mutation routes have been
|
| 487 |
581 |
|
/// registered; downstream code may then attach global layers, mount
|
| 488 |
582 |
|
/// static-file services, and add GET-only routes.
|
|
583 |
+ |
///
|
|
584 |
+ |
/// The posture-independent [`origin_gate`] is layered on here so it wraps
|
|
585 |
+ |
/// every route the `CsrfRouter` registered (the one non-skippable CSRF
|
|
586 |
+ |
/// check covering all postures at once); safe methods pass through, so the
|
|
587 |
+ |
/// later-added GET/static routes are unaffected.
|
| 489 |
588 |
|
pub fn finalize(self) -> Router<S> {
|
| 490 |
|
- |
self.0
|
|
589 |
+ |
self.0.layer(from_fn(origin_gate))
|
| 491 |
590 |
|
}
|
| 492 |
591 |
|
}
|
| 493 |
592 |
|
|
| 537 |
636 |
|
};
|
| 538 |
637 |
|
}
|
| 539 |
638 |
|
|
| 540 |
|
- |
// No header token — check if the user is authenticated
|
| 541 |
|
- |
let has_user: bool = session
|
| 542 |
|
- |
.get::<crate::auth::SessionUser>("user")
|
| 543 |
|
- |
.await
|
| 544 |
|
- |
.ok()
|
| 545 |
|
- |
.flatten()
|
| 546 |
|
- |
.is_some();
|
| 547 |
|
- |
|
| 548 |
|
- |
if !has_user {
|
| 549 |
|
- |
return next.run(request).await;
|
| 550 |
|
- |
}
|
| 551 |
|
- |
|
| 552 |
|
- |
// Authenticated user without header token — check form body for `_csrf`.
|
|
639 |
+ |
// No header token — fall through to the form-body `_csrf` check.
|
|
640 |
+ |
//
|
|
641 |
+ |
// CHRONIC A' (2026-06-15): this previously skipped validation entirely for
|
|
642 |
+ |
// logged-out callers (`if !has_user { return next.run … }`), which let a
|
|
643 |
+ |
// cross-site forged POST to a public form (e.g. `/forgot-password`) reach
|
|
644 |
+ |
// the handler. The skip is gone: every mutating request now requires a
|
|
645 |
+ |
// valid token regardless of auth state. Legitimate logged-out forms carry
|
|
646 |
+ |
// one — `get_or_create_token` stamps the anonymous session on the GET
|
|
647 |
+ |
// render and the template embeds `_csrf`. The posture-independent
|
|
648 |
+ |
// `origin_gate` (see `finalize`) is the complementary seal for browser
|
|
649 |
+ |
// forgery; this token check additionally covers header-less forged clients
|
|
650 |
+ |
// that the origin gate intentionally lets through.
|
| 553 |
651 |
|
// We only parse `application/x-www-form-urlencoded`. Other content
|
| 554 |
652 |
|
// types are rejected here:
|
| 555 |
653 |
|
// - `multipart/form-data` is the closest near-miss: it has its own
|
| 795 |
893 |
|
let truncated = &token[..token.len() - 1];
|
| 796 |
894 |
|
assert!(!constant_time_compare(&token, truncated));
|
| 797 |
895 |
|
}
|
|
896 |
+ |
|
|
897 |
+ |
#[test]
|
|
898 |
+ |
fn url_host_strips_scheme_port_and_path() {
|
|
899 |
+ |
assert_eq!(url_host("https://makenot.work").as_deref(), Some("makenot.work"));
|
|
900 |
+ |
assert_eq!(url_host("https://makenot.work:8443").as_deref(), Some("makenot.work"));
|
|
901 |
+ |
assert_eq!(
|
|
902 |
+ |
url_host("https://makenot.work/forgot-password?x=1").as_deref(),
|
|
903 |
+ |
Some("makenot.work")
|
|
904 |
+ |
);
|
|
905 |
+ |
assert_eq!(url_host("http://EXAMPLE.com").as_deref(), Some("example.com"));
|
|
906 |
+ |
assert_eq!(url_host("https://[::1]:8080/p").as_deref(), Some("[::1]"));
|
|
907 |
+ |
}
|
|
908 |
+ |
|
|
909 |
+ |
#[test]
|
|
910 |
+ |
fn url_host_rejects_opaque_and_schemeless() {
|
|
911 |
+ |
assert_eq!(url_host("null"), None);
|
|
912 |
+ |
assert_eq!(url_host("makenot.work"), None); // no scheme => unusable signal
|
|
913 |
+ |
assert_eq!(url_host("https://"), None);
|
|
914 |
+ |
}
|
|
915 |
+ |
|
|
916 |
+ |
fn headers(pairs: &[(&str, &str)]) -> axum::http::HeaderMap {
|
|
917 |
+ |
let mut h = axum::http::HeaderMap::new();
|
|
918 |
+ |
for (k, v) in pairs {
|
|
919 |
+ |
h.insert(
|
|
920 |
+ |
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
|
921 |
+ |
axum::http::HeaderValue::from_str(v).unwrap(),
|
|
922 |
+ |
);
|
|
923 |
+ |
}
|
|
924 |
+ |
h
|
|
925 |
+ |
}
|
|
926 |
+ |
|
|
927 |
+ |
#[test]
|
|
928 |
+ |
fn cross_site_sec_fetch_site_is_authoritative() {
|
|
929 |
+ |
assert!(is_cross_site(&headers(&[("sec-fetch-site", "cross-site")])));
|
|
930 |
+ |
assert!(!is_cross_site(&headers(&[("sec-fetch-site", "same-origin")])));
|
|
931 |
+ |
assert!(!is_cross_site(&headers(&[("sec-fetch-site", "same-site")])));
|
|
932 |
+ |
assert!(!is_cross_site(&headers(&[("sec-fetch-site", "none")])));
|
|
933 |
+ |
// case-insensitive
|
|
934 |
+ |
assert!(is_cross_site(&headers(&[("sec-fetch-site", "Cross-Site")])));
|
|
935 |
+ |
}
|
|
936 |
+ |
|
|
937 |
+ |
#[test]
|
|
938 |
+ |
fn cross_site_origin_fallback_compares_host() {
|
|
939 |
+ |
// Sec-Fetch-Site absent => fall back to Origin vs Host.
|
|
940 |
+ |
assert!(is_cross_site(&headers(&[
|
|
941 |
+ |
("host", "makenot.work"),
|
|
942 |
+ |
("origin", "https://evil.example"),
|
|
943 |
+ |
])));
|
|
944 |
+ |
assert!(!is_cross_site(&headers(&[
|
|
945 |
+ |
("host", "makenot.work"),
|
|
946 |
+ |
("origin", "https://makenot.work"),
|
|
947 |
+ |
])));
|
|
948 |
+ |
// port differences don't matter (same host)
|
|
949 |
+ |
assert!(!is_cross_site(&headers(&[
|
|
950 |
+ |
("host", "makenot.work:443"),
|
|
951 |
+ |
("origin", "https://makenot.work"),
|
|
952 |
+ |
])));
|
|
953 |
+ |
// Referer used only when Origin is absent
|
|
954 |
+ |
assert!(is_cross_site(&headers(&[
|
|
955 |
+ |
("host", "makenot.work"),
|
|
956 |
+ |
("referer", "https://evil.example/x"),
|
|
957 |
+ |
])));
|
|
958 |
+ |
}
|
|
959 |
+ |
|
|
960 |
+ |
#[test]
|
|
961 |
+ |
fn cross_site_no_signal_is_allowed() {
|
|
962 |
+ |
// Header-less client (server-to-server, CLI): nothing to compare => allow.
|
|
963 |
+ |
assert!(!is_cross_site(&headers(&[("host", "makenot.work")])));
|
|
964 |
+ |
assert!(!is_cross_site(&headers(&[])));
|
|
965 |
+ |
// Opaque/unparseable Origin yields no host => allow (positive-only policy).
|
|
966 |
+ |
assert!(!is_cross_site(&headers(&[
|
|
967 |
+ |
("host", "makenot.work"),
|
|
968 |
+ |
("origin", "null"),
|
|
969 |
+ |
])));
|
|
970 |
+ |
}
|
| 798 |
971 |
|
}
|