Skip to main content

max / makenotwork

ux: seal cookie-auth mutations to the CsrfRouter, at-rule + XSS tests Drive UX Wiring A- -> A+ (ultra-fuzz Run 7 --deep). D2 constructive seal: a build-time scan (csrf::every_raw_mutation_outside_ csrf_router_is_justified) over the router surfaces merged raw in lib.rs (page_routes, git_routes, sso_routes, embed_routes) asserts every raw mutating method-router (post(/put(/patch(/delete( as a free fn, not post_csrf) is a declared, justified CSRF_CARVE_OUTS entry. Today that is exactly the two git smart-HTTP POSTs (PAT/bearer-authed, not cookie). A future cookie-authed POST added to a carve-out surface instead of post_csrf now fails CI rather than shipping a silent CSRF hole. css_sanitizer: per-at-rule rejection tests for the newer blocked variants (@container, @scope, @starting-style, @view-transition) -- the exhaustive match's compile-check already bans a variant in two arms; these pin that lightningcss parses each into the variant the match blocks and it is actually stripped + recorded. docengine: extend the permissive-sanitizer break-glass XSS suite (SVG + inline script, data:text/html links). The regression tests are the seal against an ammonia default weakening; kept ammonia at the latest "4" per the project's latest-deps rule rather than pinning (a weaker, convention-violating defense). Reconciliation: the markdown break-glass doc and the CSS exhaustive-match seal were already in place from the prior pass; no churn there. Gate: cargo clippy --all-targets clean, cargo test --lib (1775), cargo test --test integration (987); docengine cargo test green. No version bump, no deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 01:37 UTC
Commit: 1172a83e616635beae3c3ef734b93cc7015bddf4
Parent: 5a70e76
3 files changed, +188 insertions, -0 deletions
@@ -730,6 +730,32 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
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,4 +1005,92 @@ mod tests {
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 }
@@ -812,6 +812,61 @@ mod tests {
812 812 );
813 813 }
814 814 }
815 +
816 + // ── Newer blocked at-rules (the UX-S2 exhaustive-match additions) ──
817 + //
818 + // The match in `visit_rule` has no wildcard arm, so a future lightningcss
819 + // variant fails to COMPILE until triaged — the compiler enforces that no
820 + // variant is both blocked and allowed (a variant can't appear in two arms of
821 + // one match). These tests pin the RUNTIME behavior the compile-check can't:
822 + // that lightningcss parses each of these at-rules into the variant the match
823 + // blocks, so they are actually stripped and recorded rather than emitted.
824 +
825 + fn assert_blocked_at_rule(css: &str, marker: &str) {
826 + let (out, rej) = san(css);
827 + assert!(
828 + !out.to_lowercase().contains(marker),
829 + "blocked at-rule `{marker}` leaked into output: {out}"
830 + );
831 + assert!(
832 + rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule),
833 + "no BlockedAtRule rejection recorded for `{marker}`"
834 + );
835 + // A sibling plain rule still survives — only the at-rule is dropped.
836 + assert!(out.contains("color"), "sibling style rule was lost: {out}");
837 + }
838 +
839 + #[test]
840 + fn rejects_container() {
841 + assert_blocked_at_rule(
842 + "@container (min-width: 100px) { p { background: red } } p { color: red }",
843 + "@container",
844 + );
845 + }
846 +
847 + #[test]
848 + fn rejects_scope() {
849 + assert_blocked_at_rule(
850 + "@scope (.a) { p { background: red } } p { color: red }",
851 + "@scope",
852 + );
853 + }
854 +
855 + #[test]
856 + fn rejects_starting_style() {
857 + assert_blocked_at_rule(
858 + "@starting-style { p { background: red } } p { color: red }",
859 + "@starting-style",
860 + );
861 + }
862 +
863 + #[test]
864 + fn rejects_view_transition() {
865 + assert_blocked_at_rule(
866 + "@view-transition { navigation: auto } p { color: red }",
867 + "@view-transition",
868 + );
869 + }
815 870 }
816 871
817 872 #[cfg(test)]
@@ -136,4 +136,23 @@ mod tests {
136 136 assert!(!result.contains("<object"), "object must be stripped: {result}");
137 137 assert!(!result.contains("<embed"), "embed must be stripped: {result}");
138 138 }
139 +
140 + #[test]
141 + fn permissive_strips_svg_and_inline_script() {
142 + // SVG is a script-carrier (`<svg><script>` / animated `<set>`); ammonia's
143 + // default allowlist excludes it. Pin that so a default change can't admit
144 + // an SVG XSS vector into creator long-form content.
145 + let html = r#"<p>x</p><svg><script>alert(1)</script></svg>"#;
146 + let result = SanitizePreset::Permissive.clean(html);
147 + assert!(!result.contains("<svg"), "svg must be stripped: {result}");
148 + assert!(!result.contains("<script"), "inline svg script must be stripped: {result}");
149 + }
150 +
151 + #[test]
152 + fn permissive_strips_data_uri_links() {
153 + // `data:text/html` navigations execute script in the document origin.
154 + let html = r#"<a href="data:text/html,<script>alert(1)</script>">x</a>"#;
155 + let result = SanitizePreset::Permissive.clean(html);
156 + assert!(!result.contains("data:text/html"), "data: URL must be stripped: {result}");
157 + }
139 158 }