| 730 |
730 |
|
}
|
| 731 |
731 |
|
}
|
| 732 |
732 |
|
|
|
733 |
+ |
/// Mutating routes that are deliberately merged OUTSIDE the finalized
|
|
734 |
+ |
/// [`CsrfRouter`] tree (registered with a raw `post(...)` rather than
|
|
735 |
+ |
/// `post_csrf(...)`), and so are NOT protected by cookie-CSRF.
|
|
736 |
+ |
///
|
|
737 |
+ |
/// Each is safe only because it authenticates with a NON-cookie credential — a
|
|
738 |
+ |
/// git Personal Access Token / bearer over the git smart-HTTP wire protocol —
|
|
739 |
+ |
/// so a browser can't be tricked into driving it cross-site. Adding a
|
|
740 |
+ |
/// cookie-authed mutation to a raw-merged surface (`page_routes`, `git_routes`,
|
|
741 |
+ |
/// `sso_routes`, `embed_routes` in `lib.rs`) is a CSRF hole: route it through
|
|
742 |
+ |
/// `post_csrf`/the `CsrfRouter` instead. The build-time scan
|
|
743 |
+ |
/// `every_raw_mutation_outside_csrf_router_is_justified` fails if a raw mutating
|
|
744 |
+ |
/// route appears in those surfaces without a matching entry here.
|
|
745 |
+ |
///
|
|
746 |
+ |
/// Entries match by a substring of the registering line (the handler path).
|
|
747 |
+ |
#[cfg(test)]
|
|
748 |
+ |
const CSRF_CARVE_OUTS: &[(&str, &str)] = &[
|
|
749 |
+ |
(
|
|
750 |
+ |
"smart_http_upload_pack",
|
|
751 |
+ |
"git fetch/clone over smart-HTTP; bearer/PAT- or public-repo-authed, never a session cookie",
|
|
752 |
+ |
),
|
|
753 |
+ |
(
|
|
754 |
+ |
"smart_http_receive_pack",
|
|
755 |
+ |
"git push over smart-HTTP; requires a push-scoped PAT and rejects cookie auth (240a4ca)",
|
|
756 |
+ |
),
|
|
757 |
+ |
];
|
|
758 |
+ |
|
| 733 |
759 |
|
#[cfg(test)]
|
| 734 |
760 |
|
mod tests {
|
| 735 |
761 |
|
use super::*;
|
| 979 |
1005 |
|
("origin", "null"),
|
| 980 |
1006 |
|
])));
|
| 981 |
1007 |
|
}
|
|
1008 |
+ |
|
|
1009 |
+ |
// ── CSRF carve-out manifest: every cookie-authable mutation is sealed ──
|
|
1010 |
+ |
|
|
1011 |
+ |
/// True if `verb(` occurs in `line` as a free-function call (axum's
|
|
1012 |
+ |
/// `post`/`put`/`patch`/`delete` method routers) rather than a method call
|
|
1013 |
+ |
/// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(` — the `(`
|
|
1014 |
+ |
/// must immediately follow the verb, which excludes `post_csrf`).
|
|
1015 |
+ |
fn has_raw_method_router(line: &str, verb: &str) -> bool {
|
|
1016 |
+ |
let needle = format!("{verb}(");
|
|
1017 |
+ |
let mut from = 0;
|
|
1018 |
+ |
while let Some(rel) = line[from..].find(&needle) {
|
|
1019 |
+ |
let at = from + rel;
|
|
1020 |
+ |
let prev = line[..at].chars().next_back();
|
|
1021 |
+ |
// Reject `.post(` (method call) and `xpost(` (identifier suffix like
|
|
1022 |
+ |
// `post_csrf` can't reach here — `(` follows the verb directly).
|
|
1023 |
+ |
if !matches!(prev, Some(c) if c == '.' || c.is_alphanumeric() || c == '_') {
|
|
1024 |
+ |
return true;
|
|
1025 |
+ |
}
|
|
1026 |
+ |
from = at + needle.len();
|
|
1027 |
+ |
}
|
|
1028 |
+ |
false
|
|
1029 |
+ |
}
|
|
1030 |
+ |
|
|
1031 |
+ |
/// Build-time seal (the D2 constructive fix): the router surfaces merged raw
|
|
1032 |
+ |
/// in `lib.rs` outside the finalized `CsrfRouter` must not register any
|
|
1033 |
+ |
/// mutating route that isn't a declared, justified [`CSRF_CARVE_OUTS`] entry.
|
|
1034 |
+ |
/// A future cookie-authed `post(...)` added to a carve-out surface — instead
|
|
1035 |
+ |
/// of `post_csrf(...)` — fails here rather than shipping a silent CSRF hole.
|
|
1036 |
+ |
#[test]
|
|
1037 |
+ |
fn every_raw_mutation_outside_csrf_router_is_justified() {
|
|
1038 |
+ |
use std::path::Path;
|
|
1039 |
+ |
|
|
1040 |
+ |
fn scan(path: &Path, contents: &str, offenders: &mut Vec<String>) {
|
|
1041 |
+ |
for (i, line) in contents.lines().enumerate() {
|
|
1042 |
+ |
let trimmed = line.trim_start();
|
|
1043 |
+ |
if trimmed.starts_with("//") || trimmed.starts_with('*') {
|
|
1044 |
+ |
continue;
|
|
1045 |
+ |
}
|
|
1046 |
+ |
let is_mutation = ["post", "put", "patch", "delete"]
|
|
1047 |
+ |
.iter()
|
|
1048 |
+ |
.any(|v| has_raw_method_router(line, v));
|
|
1049 |
+ |
if is_mutation
|
|
1050 |
+ |
&& !CSRF_CARVE_OUTS.iter().any(|(handler, _)| line.contains(handler))
|
|
1051 |
+ |
{
|
|
1052 |
+ |
offenders.push(format!("{}:{}: {}", path.display(), i + 1, trimmed));
|
|
1053 |
+ |
}
|
|
1054 |
+ |
}
|
|
1055 |
+ |
}
|
|
1056 |
+ |
|
|
1057 |
+ |
fn walk(dir: &Path, offenders: &mut Vec<String>) {
|
|
1058 |
+ |
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
|
1059 |
+ |
for entry in entries.flatten() {
|
|
1060 |
+ |
let path = entry.path();
|
|
1061 |
+ |
if path.is_dir() {
|
|
1062 |
+ |
walk(&path, offenders);
|
|
1063 |
+ |
} else if path.extension().is_some_and(|e| e == "rs")
|
|
1064 |
+ |
&& let Ok(contents) = std::fs::read_to_string(&path)
|
|
1065 |
+ |
{
|
|
1066 |
+ |
scan(&path, &contents, offenders);
|
|
1067 |
+ |
}
|
|
1068 |
+ |
}
|
|
1069 |
+ |
}
|
|
1070 |
+ |
|
|
1071 |
+ |
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
1072 |
+ |
// The surfaces merged raw in lib.rs, outside `.finalize()`.
|
|
1073 |
+ |
let surfaces = [
|
|
1074 |
+ |
"src/routes/pages",
|
|
1075 |
+ |
"src/routes/git",
|
|
1076 |
+ |
"src/routes/embed",
|
|
1077 |
+ |
];
|
|
1078 |
+ |
let mut offenders = Vec::new();
|
|
1079 |
+ |
for s in surfaces {
|
|
1080 |
+ |
walk(&base.join(s), &mut offenders);
|
|
1081 |
+ |
}
|
|
1082 |
+ |
// sso.rs is a single file, not a directory.
|
|
1083 |
+ |
let sso = base.join("src/routes/sso.rs");
|
|
1084 |
+ |
if let Ok(contents) = std::fs::read_to_string(&sso) {
|
|
1085 |
+ |
scan(&sso, &contents, &mut offenders);
|
|
1086 |
+ |
}
|
|
1087 |
+ |
|
|
1088 |
+ |
assert!(
|
|
1089 |
+ |
offenders.is_empty(),
|
|
1090 |
+ |
"Found cookie-authable mutation(s) merged OUTSIDE the CsrfRouter tree with no \
|
|
1091 |
+ |
justified CSRF_CARVE_OUTS entry. Route these through post_csrf/the CsrfRouter, \
|
|
1092 |
+ |
or (if genuinely non-cookie-authed) add a documented CSRF_CARVE_OUTS entry:\n{}",
|
|
1093 |
+ |
offenders.join("\n")
|
|
1094 |
+ |
);
|
|
1095 |
+ |
}
|
| 982 |
1096 |
|
}
|