never serve /raw/ as an active document (#61)
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
--- 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
--- 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
--- /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+}