never serve /raw/ as an active document (#61)

275d11cgl0w

all commits

Message

never serve /raw/ as an active document (#61)

Committed bytes are untrusted: raw/evil.html used to come back as
text/html and run script in the site origin, next to /inbox/*.
serve now forces application/octet-stream + Content-Disposition:
attachment for active types (html/htm/xhtml/svg/xml/pdf/...), keeps
text as text/plain inline, and downloads everything else. build emits
a Cloudflare Pages _headers file with the same raw policy plus
nosniff/referrer defaults. Tests cover GET and HEAD, text vs active,
and the emitted _headers.

Diff

src/build.rs+18 −0
diff --git a/src/build.rs b/src/build.rsindex e3ba54a..52aeedb 100644--- a/src/build.rs+++ b/src/build.rs@@ -844,6 +844,22 @@ fn write_style(out: &Path) -> Result<(), String> {844844 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`845845 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).846846 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`847+/// Header rules for Cloudflare Pages (and any host that reads `_headers`).848+///849+/// `/raw/**` holds committed, untrusted bytes: a repo can contain850+/// `raw/evil.html` signing script in the site's own origin, where the851+/// inbox endpoints live. `serve` sets the same policy directly852+/// (f0rge #61); this file gives Pages the equivalent.853+pub const HEADERS_FILE: &str = "\854+# generated by hu13: committed files are untrusted (f0rge #61)855+/raw/*856+  Content-Disposition: attachment857+  X-Content-Type-Options: nosniff858+/*859+  X-Content-Type-Options: nosniff860+  Referrer-Policy: strict-origin-when-cross-origin861+";862+847863 /// (diamond-hub + hu13 as <path>, no <text>/fonts), the favicon is the848864 /// simplified mark-only variant for 16px, and the manifest points at the849865 /// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui).@@ -856,6 +872,8 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {856872     let manifest = include_str!("../static/manifest.webmanifest");857873     std::fs::write(out.join("manifest.webmanifest"), manifest)858874         .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;875+    std::fs::write(out.join("_headers"), HEADERS_FILE)876+        .map_err(|e| format!("hu13: write _headers: {e}"))?;859877     Ok(())860878 }861879 
src/serve.rs+107 −4
diff --git a/src/serve.rs b/src/serve.rsindex a119e85..0b06ef3 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -411,6 +411,92 @@ fn content_type_for(name: &str) -> &'static str {411411     }412412 }413413 414+/// Extensions that browsers may render as an active document. Committed415+/// files are untrusted (a repo can contain `raw/evil.html`), so these are416+/// always downloaded, never rendered (f0rge #61).417+const RAW_ACTIVE_EXTS: &[&str] = &[418+    "html", "htm", "shtml", "xhtml", "xht", "svg", "svgz", "xml", "xsl", "xslt", "mht", "mhtml",419+    "pdf", "swf", "hta", "jar", "crx", "xpi",420+];421+422+/// Text-ish extensions safe to show inline from `/raw/`.423+const RAW_TEXT_EXTS: &[&str] = &[424+    "txt",425+    "text",426+    "md",427+    "markdown",428+    "rst",429+    "adoc",430+    "rs",431+    "js",432+    "mjs",433+    "cjs",434+    "jsx",435+    "ts",436+    "tsx",437+    "json",438+    "jsonl",439+    "yaml",440+    "yml",441+    "toml",442+    "ini",443+    "cfg",444+    "conf",445+    "css",446+    "scss",447+    "less",448+    "c",449+    "h",450+    "cc",451+    "cpp",452+    "hpp",453+    "go",454+    "py",455+    "rb",456+    "sh",457+    "bash",458+    "zsh",459+    "fish",460+    "lua",461+    "sql",462+    "log",463+    "diff",464+    "patch",465+    "csv",466+    "tsv",467+    "atom",468+    "rss",469+    "lock",470+    "gitignore",471+    "gitattributes",472+    "editorconfig",473+];474+475+fn extension(name: &str) -> String {476+    let base = name.rsplit('/').next().unwrap_or(name);477+    match base.rsplit_once('.') {478+        Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(),479+        _ => String::new(),480+    }481+}482+483+/// `(content_type, content_disposition)` for a `/raw/<path>` response.484+///485+/// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run486+/// script in the site's origin (where the inbox endpoints live). Active487+/// types become an octet-stream download; text stays inline as488+/// `text/plain`; everything else downloads.489+fn raw_content_type(name: &str) -> (&'static str, &'static str) {490+    let ext = extension(name);491+    if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {492+        ("application/octet-stream", "attachment")493+    } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {494+        ("text/plain; charset=utf-8", "inline")495+    } else {496+        ("application/octet-stream", "attachment")497+    }498+}499+414500 /// Percent-decode a URL path (`+` stays literal in paths). `None` = bad415501 /// escape / NUL / non-UTF-8 → caller answers `400`/drops.416502 fn decode_path(s: &str) -> Option<String> {@@ -568,17 +654,34 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {568654         static_error(&mut stream, 405);569655         return;570656     }571-    let ctype = content_type_for(&file_rel);657+    let is_raw = file_rel.starts_with("raw/");658+    let (ctype, disposition) = if is_raw {659+        raw_content_type(&file_rel)660+    } else {661+        (content_type_for(&file_rel), "inline")662+    };663+    let extra: &[(&str, &str)] = if is_raw {664+        &[("Content-Disposition", disposition)]665+    } else {666+        &[]667+    };572668     if head.method == "HEAD" {573-        let out = format!(574-            "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",669+        let mut out = format!(670+            "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",575671             bytes.len()576672         );673+        for (k, v) in extra {674+            out.push_str(k);675+            out.push_str(": ");676+            out.push_str(v);677+            out.push_str("\r\n");678+        }679+        out.push_str("\r\n");577680         let _ = stream.write_all(out.as_bytes());578681         let _ = stream.flush();579682         return;580683     }581-    respond(&mut stream, 200, ctype, &[], &bytes);684+    respond(&mut stream, 200, ctype, extra, &bytes);582685 }583686 584687 /// Read-and-discard in-flight request bytes (bounded) so early rejects
tests/raw_safety.rs+231 −0
diff --git a/tests/raw_safety.rs b/tests/raw_safety.rsnew file mode 100644index 0000000..349a1b5--- /dev/null+++ b/tests/raw_safety.rs@@ -0,0 +1,231 @@1+//! f0rge #61: `/raw/<path>` must never serve committed bytes as an active2+//! document. A repo can contain `raw/evil.html`; served as `text/html` it3+//! runs script in the site's origin (where `/inbox/*` lives).4+5+use std::io::{BufRead, BufReader, Read, Write};6+use std::net::TcpStream;7+use std::path::{Path, PathBuf};8+use std::process::{Child, Command, Stdio};9+use std::sync::atomic::{AtomicU64, Ordering};10+use std::time::Duration;11+12+use std::sync::atomic::AtomicU64 as _A;13+static SEQ: AtomicU64 = AtomicU64::new(0);14+const _: fn() = || {15+    let _ = _A::new(0);16+};17+18+fn unique(prefix: &str) -> PathBuf {19+    std::env::temp_dir().join(format!(20+        "hu13-raw-{prefix}-{}-{}",21+        std::process::id(),22+        SEQ.fetch_add(1, Ordering::SeqCst)23+    ))24+}25+26+fn fresh(prefix: &str) -> PathBuf {27+    let p = unique(prefix);28+    std::fs::create_dir_all(&p).unwrap();29+    p30+}31+32+struct Server {33+    child: Child,34+    addr: String,35+    _www: PathBuf,36+    _inbox: PathBuf,37+}38+39+impl Drop for Server {40+    fn drop(&mut self) {41+        self.child.kill().ok();42+        self.child.wait().ok();43+    }44+}45+46+fn start(www: &Path, inbox: &Path) -> Server {47+    let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))48+        .arg("serve")49+        .arg("--www")50+        .arg(www)51+        .arg("--inbox-dir")52+        .arg(inbox)53+        .arg("--listen")54+        .arg("127.0.0.1:0")55+        .stdout(Stdio::piped())56+        .stderr(Stdio::null())57+        .spawn()58+        .expect("spawn hu13 serve");59+    let stdout = child.stdout.take().expect("piped stdout");60+    let mut lines = BufReader::new(stdout).lines();61+    let mut addr = String::new();62+    for _ in 0..50 {63+        if let Some(Ok(line)) = lines.next() {64+            if let Some(rest) = line.split("listening on ").nth(1) {65+                addr = rest.trim().to_string();66+                break;67+            }68+        }69+    }70+    assert!(!addr.is_empty(), "serve printed no listening line");71+    std::thread::sleep(Duration::from_millis(100));72+    Server {73+        child,74+        addr,75+        _www: www.to_path_buf(),76+        _inbox: inbox.to_path_buf(),77+    }78+}79+80+fn get(addr: &str, path: &str) -> String {81+    let mut s = TcpStream::connect(addr).expect("connect");82+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();83+    write!(84+        s,85+        "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"86+    )87+    .unwrap();88+    let mut out = Vec::new();89+    s.read_to_end(&mut out).unwrap();90+    String::from_utf8_lossy(&out).into_owned()91+}92+93+fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {94+    resp.lines()95+        .find(|l| {96+            l.to_ascii_lowercase()97+                .starts_with(&format!("{}:", name.to_ascii_lowercase()))98+        })99+        .and_then(|l| l.split_once(':'))100+        .map(|(_, v)| v.trim())101+}102+103+#[test]104+fn raw_active_types_download_never_render() {105+    let www = fresh("www");106+    let inbox = fresh("inbox");107+    for (name, body) in [108+        ("evil.html", "<script>alert(document.domain)</script>"),109+        (110+            "evil.svg",111+            "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",112+        ),113+        ("evil.xml", "<script>alert(1)</script>"),114+        ("evil.xhtml", "<script>alert(1)</script>"),115+        ("note.txt", "plain text"),116+        ("code.rs", "fn main() {}"),117+        ("blob.bin", "\x00\x01binary"),118+    ] {119+        std::fs::create_dir_all(www.join("raw")).unwrap();120+        std::fs::write(www.join("raw").join(name), body).unwrap();121+    }122+    let srv = start(&www, &inbox);123+124+    for path in [125+        "/raw/evil.html",126+        "/raw/evil.svg",127+        "/raw/evil.xml",128+        "/raw/evil.xhtml",129+    ] {130+        let resp = get(&srv.addr, path);131+        assert!(resp.starts_with("HTTP/1.1 200"), "{path}: {resp}");132+        let ctype = header(&resp, "Content-Type").unwrap_or("");133+        assert!(134+            !ctype.contains("html") && !ctype.contains("svg") && !ctype.contains("xml"),135+            "{path} must not be an active type, got {ctype}"136+        );137+        assert_eq!(ctype, "application/octet-stream", "{path}");138+        let disp = header(&resp, "Content-Disposition").unwrap_or("");139+        assert!(disp.starts_with("attachment"), "{path}: {disp:?}");140+        assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff"));141+    }142+143+    for path in ["/raw/note.txt", "/raw/code.rs"] {144+        let resp = get(&srv.addr, path);145+        assert_eq!(146+            header(&resp, "Content-Type"),147+            Some("text/plain; charset=utf-8"),148+            "{path}"149+        );150+        assert_eq!(header(&resp, "Content-Disposition"), Some("inline"));151+    }152+153+    let resp = get(&srv.addr, "/raw/blob.bin");154+    assert_eq!(155+        header(&resp, "Content-Type"),156+        Some("application/octet-stream")157+    );158+    assert_eq!(header(&resp, "Content-Disposition"), Some("attachment"));159+160+    // HEAD carries the same policy.161+    let mut s = TcpStream::connect(&srv.addr).unwrap();162+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();163+    write!(164+        s,165+        "HEAD /raw/evil.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"166+    )167+    .unwrap();168+    let mut out = Vec::new();169+    s.read_to_end(&mut out).unwrap();170+    let head = String::from_utf8_lossy(&out);171+    assert_eq!(172+        header(&head, "Content-Disposition"),173+        Some("attachment"),174+        "HEAD: {head}"175+    );176+}177+178+#[test]179+fn build_emits_pages_headers_file() {180+    let repo = fresh("repo");181+    let out = fresh("out");182+    for args in [183+        ["init", "-b", "main"].as_slice(),184+        &["config", "user.name", "hu13-test"],185+        &["config", "user.email", "[email protected]"],186+        &["config", "commit.gpgsign", "false"],187+    ] {188+        let st = Command::new("git")189+            .arg("-C")190+            .arg(&repo)191+            .args(args)192+            .status()193+            .unwrap();194+        assert!(st.success());195+    }196+    std::fs::write(repo.join("README.md"), "# raw\n").unwrap();197+    std::fs::write(repo.join("evil.html"), "<script>alert(1)</script>").unwrap();198+    let st = Command::new("git")199+        .arg("-C")200+        .arg(&repo)201+        .args(["add", "-A"])202+        .status()203+        .unwrap();204+    assert!(st.success());205+    let st = Command::new("git")206+        .arg("-C")207+        .arg(&repo)208+        .args(["commit", "-m", "raw"])209+        .status()210+        .unwrap();211+    assert!(st.success());212+213+    let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))214+        .arg("build")215+        .arg(&repo)216+        .arg("-o")217+        .arg(&out)218+        .output()219+        .unwrap();220+    assert!(221+        produced.status.success(),222+        "{}",223+        String::from_utf8_lossy(&produced.stderr)224+    );225+    let headers = std::fs::read_to_string(out.join("_headers")).expect("_headers");226+    assert!(headers.contains("/raw/*"), "{headers}");227+    assert!(228+        headers.contains("Content-Disposition: attachment"),229+        "{headers}"230+    );231+}