From 275d11c7930da27f701e9bc1b61de8148597f77e Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 15:43:40 +0000 Subject: [PATCH] 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. --- src/build.rs | 18 ++++ src/serve.rs | 111 ++++++++++++++++++++- tests/raw_safety.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 tests/raw_safety.rs diff --git a/src/build.rs b/src/build.rs index e3ba54a..52aeedb 100644 --- a/src/build.rs +++ b/src/build.rs @@ -844,6 +844,22 @@ fn write_style(out: &Path) -> Result<(), String> { /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest` /// from the embedded static files (m6 favicon #16; logo-text #27 + #28). /// SVG-first, zero binary blobs: the header lockup is `logo.svg` +/// Header rules for Cloudflare Pages (and any host that reads `_headers`). +/// +/// `/raw/**` holds committed, untrusted bytes: a repo can contain +/// `raw/evil.html` signing script in the site's own origin, where the +/// inbox endpoints live. `serve` sets the same policy directly +/// (f0rge #61); this file gives Pages the equivalent. +pub const HEADERS_FILE: &str = "\ +# generated by hu13: committed files are untrusted (f0rge #61) +/raw/* + Content-Disposition: attachment + X-Content-Type-Options: nosniff +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin +"; + /// (diamond-hub + hu13 as , no /fonts), the favicon is the /// simplified mark-only variant for 16px, and the manifest points at the /// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui). @@ -856,6 +872,8 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> { let manifest = include_str!("../static/manifest.webmanifest"); std::fs::write(out.join("manifest.webmanifest"), manifest) .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?; + std::fs::write(out.join("_headers"), HEADERS_FILE) + .map_err(|e| format!("hu13: write _headers: {e}"))?; Ok(()) } diff --git a/src/serve.rs b/src/serve.rs index a119e85..0b06ef3 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -411,6 +411,92 @@ fn content_type_for(name: &str) -> &'static str { } } +/// Extensions that browsers may render as an active document. Committed +/// files are untrusted (a repo can contain `raw/evil.html`), so these are +/// always downloaded, never rendered (f0rge #61). +const RAW_ACTIVE_EXTS: &[&str] = &[ + "html", "htm", "shtml", "xhtml", "xht", "svg", "svgz", "xml", "xsl", "xslt", "mht", "mhtml", + "pdf", "swf", "hta", "jar", "crx", "xpi", +]; + +/// Text-ish extensions safe to show inline from `/raw/`. +const RAW_TEXT_EXTS: &[&str] = &[ + "txt", + "text", + "md", + "markdown", + "rst", + "adoc", + "rs", + "js", + "mjs", + "cjs", + "jsx", + "ts", + "tsx", + "json", + "jsonl", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "css", + "scss", + "less", + "c", + "h", + "cc", + "cpp", + "hpp", + "go", + "py", + "rb", + "sh", + "bash", + "zsh", + "fish", + "lua", + "sql", + "log", + "diff", + "patch", + "csv", + "tsv", + "atom", + "rss", + "lock", + "gitignore", + "gitattributes", + "editorconfig", +]; + +fn extension(name: &str) -> String { + let base = name.rsplit('/').next().unwrap_or(name); + match base.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(), + _ => String::new(), + } +} + +/// `(content_type, content_disposition)` for a `/raw/` response. +/// +/// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run +/// script in the site's origin (where the inbox endpoints live). Active +/// types become an octet-stream download; text stays inline as +/// `text/plain`; everything else downloads. +fn raw_content_type(name: &str) -> (&'static str, &'static str) { + let ext = extension(name); + if RAW_ACTIVE_EXTS.contains(&ext.as_str()) { + ("application/octet-stream", "attachment") + } else if RAW_TEXT_EXTS.contains(&ext.as_str()) { + ("text/plain; charset=utf-8", "inline") + } else { + ("application/octet-stream", "attachment") + } +} + /// Percent-decode a URL path (`+` stays literal in paths). `None` = bad /// escape / NUL / non-UTF-8 → caller answers `400`/drops. fn decode_path(s: &str) -> Option { @@ -568,17 +654,34 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { static_error(&mut stream, 405); return; } - let ctype = content_type_for(&file_rel); + let is_raw = file_rel.starts_with("raw/"); + let (ctype, disposition) = if is_raw { + raw_content_type(&file_rel) + } else { + (content_type_for(&file_rel), "inline") + }; + let extra: &[(&str, &str)] = if is_raw { + &[("Content-Disposition", disposition)] + } else { + &[] + }; if head.method == "HEAD" { - let out = format!( - "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", + let mut out = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n", bytes.len() ); + for (k, v) in extra { + out.push_str(k); + out.push_str(": "); + out.push_str(v); + out.push_str("\r\n"); + } + out.push_str("\r\n"); let _ = stream.write_all(out.as_bytes()); let _ = stream.flush(); return; } - respond(&mut stream, 200, ctype, &[], &bytes); + respond(&mut stream, 200, ctype, extra, &bytes); } /// Read-and-discard in-flight request bytes (bounded) so early rejects diff --git a/tests/raw_safety.rs b/tests/raw_safety.rs new file mode 100644 index 0000000..349a1b5 --- /dev/null +++ b/tests/raw_safety.rs @@ -0,0 +1,231 @@ +//! f0rge #61: `/raw/` must never serve committed bytes as an active +//! document. A repo can contain `raw/evil.html`; served as `text/html` it +//! runs script in the site's origin (where `/inbox/*` lives). + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use std::sync::atomic::AtomicU64 as _A; +static SEQ: AtomicU64 = AtomicU64::new(0); +const _: fn() = || { + let _ = _A::new(0); +}; + +fn unique(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "hu13-raw-{prefix}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::SeqCst) + )) +} + +fn fresh(prefix: &str) -> PathBuf { + let p = unique(prefix); + std::fs::create_dir_all(&p).unwrap(); + p +} + +struct Server { + child: Child, + addr: String, + _www: PathBuf, + _inbox: PathBuf, +} + +impl Drop for Server { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +fn start(www: &Path, inbox: &Path) -> Server { + let mut child = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("serve") + .arg("--www") + .arg(www) + .arg("--inbox-dir") + .arg(inbox) + .arg("--listen") + .arg("127.0.0.1:0") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn hu13 serve"); + let stdout = child.stdout.take().expect("piped stdout"); + let mut lines = BufReader::new(stdout).lines(); + let mut addr = String::new(); + for _ in 0..50 { + if let Some(Ok(line)) = lines.next() { + if let Some(rest) = line.split("listening on ").nth(1) { + addr = rest.trim().to_string(); + break; + } + } + } + assert!(!addr.is_empty(), "serve printed no listening line"); + std::thread::sleep(Duration::from_millis(100)); + Server { + child, + addr, + _www: www.to_path_buf(), + _inbox: inbox.to_path_buf(), + } +} + +fn get(addr: &str, path: &str) -> String { + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + write!( + s, + "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut out = Vec::new(); + s.read_to_end(&mut out).unwrap(); + String::from_utf8_lossy(&out).into_owned() +} + +fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> { + resp.lines() + .find(|l| { + l.to_ascii_lowercase() + .starts_with(&format!("{}:", name.to_ascii_lowercase())) + }) + .and_then(|l| l.split_once(':')) + .map(|(_, v)| v.trim()) +} + +#[test] +fn raw_active_types_download_never_render() { + let www = fresh("www"); + let inbox = fresh("inbox"); + for (name, body) in [ + ("evil.html", ""), + ( + "evil.svg", + "", + ), + ("evil.xml", ""), + ("evil.xhtml", ""), + ("note.txt", "plain text"), + ("code.rs", "fn main() {}"), + ("blob.bin", "\x00\x01binary"), + ] { + std::fs::create_dir_all(www.join("raw")).unwrap(); + std::fs::write(www.join("raw").join(name), body).unwrap(); + } + let srv = start(&www, &inbox); + + for path in [ + "/raw/evil.html", + "/raw/evil.svg", + "/raw/evil.xml", + "/raw/evil.xhtml", + ] { + let resp = get(&srv.addr, path); + assert!(resp.starts_with("HTTP/1.1 200"), "{path}: {resp}"); + let ctype = header(&resp, "Content-Type").unwrap_or(""); + assert!( + !ctype.contains("html") && !ctype.contains("svg") && !ctype.contains("xml"), + "{path} must not be an active type, got {ctype}" + ); + assert_eq!(ctype, "application/octet-stream", "{path}"); + let disp = header(&resp, "Content-Disposition").unwrap_or(""); + assert!(disp.starts_with("attachment"), "{path}: {disp:?}"); + assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff")); + } + + for path in ["/raw/note.txt", "/raw/code.rs"] { + let resp = get(&srv.addr, path); + assert_eq!( + header(&resp, "Content-Type"), + Some("text/plain; charset=utf-8"), + "{path}" + ); + assert_eq!(header(&resp, "Content-Disposition"), Some("inline")); + } + + let resp = get(&srv.addr, "/raw/blob.bin"); + assert_eq!( + header(&resp, "Content-Type"), + Some("application/octet-stream") + ); + assert_eq!(header(&resp, "Content-Disposition"), Some("attachment")); + + // HEAD carries the same policy. + let mut s = TcpStream::connect(&srv.addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + write!( + s, + "HEAD /raw/evil.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut out = Vec::new(); + s.read_to_end(&mut out).unwrap(); + let head = String::from_utf8_lossy(&out); + assert_eq!( + header(&head, "Content-Disposition"), + Some("attachment"), + "HEAD: {head}" + ); +} + +#[test] +fn build_emits_pages_headers_file() { + let repo = fresh("repo"); + let out = fresh("out"); + for args in [ + ["init", "-b", "main"].as_slice(), + &["config", "user.name", "hu13-test"], + &["config", "user.email", "hu13@test.local"], + &["config", "commit.gpgsign", "false"], + ] { + let st = Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .status() + .unwrap(); + assert!(st.success()); + } + std::fs::write(repo.join("README.md"), "# raw\n").unwrap(); + std::fs::write(repo.join("evil.html"), "").unwrap(); + let st = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["add", "-A"]) + .status() + .unwrap(); + assert!(st.success()); + let st = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["commit", "-m", "raw"]) + .status() + .unwrap(); + assert!(st.success()); + + let produced = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .output() + .unwrap(); + assert!( + produced.status.success(), + "{}", + String::from_utf8_lossy(&produced.stderr) + ); + let headers = std::fs::read_to_string(out.join("_headers")).expect("_headers"); + assert!(headers.contains("/raw/*"), "{headers}"); + assert!( + headers.contains("Content-Disposition: attachment"), + "{headers}" + ); +} -- 2.56.0