1 From 275d11c7930da27f701e9bc1b61de8148597f77e Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 15:43:40 +00004 Subject: [PATCH] never serve /raw/ as an active document (#61)5 6 Committed bytes are untrusted: raw/evil.html used to come back as7 text/html and run script in the site origin, next to /inbox/*.8 serve now forces application/octet-stream + Content-Disposition:9 attachment for active types (html/htm/xhtml/svg/xml/pdf/...), keeps10 text as text/plain inline, and downloads everything else. build emits11 a Cloudflare Pages _headers file with the same raw policy plus12 nosniff/referrer defaults. Tests cover GET and HEAD, text vs active,13 and the emitted _headers.14 ---15 src/build.rs | 18 ++++16 src/serve.rs | 111 ++++++++++++++++++++-17 tests/raw_safety.rs | 231 ++++++++++++++++++++++++++++++++++++++++++++18 3 files changed, 356 insertions(+), 4 deletions(-)19 create mode 100644 tests/raw_safety.rs20 21 diff --git a/src/build.rs b/src/build.rs22 index e3ba54a..52aeedb 10064423 --- a/src/build.rs24 +++ b/src/build.rs25 @@ -844,6 +844,22 @@ fn write_style(out: &Path) -> Result<(), String> {26 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`27 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).28 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`29 +/// Header rules for Cloudflare Pages (and any host that reads `_headers`).30 +///31 +/// `/raw/**` holds committed, untrusted bytes: a repo can contain32 +/// `raw/evil.html` signing script in the site's own origin, where the33 +/// inbox endpoints live. `serve` sets the same policy directly34 +/// (f0rge #61); this file gives Pages the equivalent.35 +pub const HEADERS_FILE: &str = "\36 +# generated by hu13: committed files are untrusted (f0rge #61)37 +/raw/*38 + Content-Disposition: attachment39 + X-Content-Type-Options: nosniff40 +/*41 + X-Content-Type-Options: nosniff42 + Referrer-Policy: strict-origin-when-cross-origin43 +";44 +45 /// (diamond-hub + hu13 as <path>, no <text>/fonts), the favicon is the46 /// simplified mark-only variant for 16px, and the manifest points at the47 /// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui).48 @@ -856,6 +872,8 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {49 let manifest = include_str!("../static/manifest.webmanifest");50 std::fs::write(out.join("manifest.webmanifest"), manifest)51 .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;52 + std::fs::write(out.join("_headers"), HEADERS_FILE)53 + .map_err(|e| format!("hu13: write _headers: {e}"))?;54 Ok(())55 }56 57 diff --git a/src/serve.rs b/src/serve.rs58 index a119e85..0b06ef3 10064459 --- a/src/serve.rs60 +++ b/src/serve.rs61 @@ -411,6 +411,92 @@ fn content_type_for(name: &str) -> &'static str {62 }63 }64 65 +/// Extensions that browsers may render as an active document. Committed66 +/// files are untrusted (a repo can contain `raw/evil.html`), so these are67 +/// always downloaded, never rendered (f0rge #61).68 +const RAW_ACTIVE_EXTS: &[&str] = &[69 + "html", "htm", "shtml", "xhtml", "xht", "svg", "svgz", "xml", "xsl", "xslt", "mht", "mhtml",70 + "pdf", "swf", "hta", "jar", "crx", "xpi",71 +];72 +73 +/// Text-ish extensions safe to show inline from `/raw/`.74 +const RAW_TEXT_EXTS: &[&str] = &[75 + "txt",76 + "text",77 + "md",78 + "markdown",79 + "rst",80 + "adoc",81 + "rs",82 + "js",83 + "mjs",84 + "cjs",85 + "jsx",86 + "ts",87 + "tsx",88 + "json",89 + "jsonl",90 + "yaml",91 + "yml",92 + "toml",93 + "ini",94 + "cfg",95 + "conf",96 + "css",97 + "scss",98 + "less",99 + "c",100 + "h",101 + "cc",102 + "cpp",103 + "hpp",104 + "go",105 + "py",106 + "rb",107 + "sh",108 + "bash",109 + "zsh",110 + "fish",111 + "lua",112 + "sql",113 + "log",114 + "diff",115 + "patch",116 + "csv",117 + "tsv",118 + "atom",119 + "rss",120 + "lock",121 + "gitignore",122 + "gitattributes",123 + "editorconfig",124 +];125 +126 +fn extension(name: &str) -> String {127 + let base = name.rsplit('/').next().unwrap_or(name);128 + match base.rsplit_once('.') {129 + Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(),130 + _ => String::new(),131 + }132 +}133 +134 +/// `(content_type, content_disposition)` for a `/raw/<path>` response.135 +///136 +/// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run137 +/// script in the site's origin (where the inbox endpoints live). Active138 +/// types become an octet-stream download; text stays inline as139 +/// `text/plain`; everything else downloads.140 +fn raw_content_type(name: &str) -> (&'static str, &'static str) {141 + let ext = extension(name);142 + if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {143 + ("application/octet-stream", "attachment")144 + } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {145 + ("text/plain; charset=utf-8", "inline")146 + } else {147 + ("application/octet-stream", "attachment")148 + }149 +}150 +151 /// Percent-decode a URL path (`+` stays literal in paths). `None` = bad152 /// escape / NUL / non-UTF-8 → caller answers `400`/drops.153 fn decode_path(s: &str) -> Option<String> {154 @@ -568,17 +654,34 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {155 static_error(&mut stream, 405);156 return;157 }158 - let ctype = content_type_for(&file_rel);159 + let is_raw = file_rel.starts_with("raw/");160 + let (ctype, disposition) = if is_raw {161 + raw_content_type(&file_rel)162 + } else {163 + (content_type_for(&file_rel), "inline")164 + };165 + let extra: &[(&str, &str)] = if is_raw {166 + &[("Content-Disposition", disposition)]167 + } else {168 + &[]169 + };170 if head.method == "HEAD" {171 - let out = format!(172 - "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",173 + let mut out = format!(174 + "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",175 bytes.len()176 );177 + for (k, v) in extra {178 + out.push_str(k);179 + out.push_str(": ");180 + out.push_str(v);181 + out.push_str("\r\n");182 + }183 + out.push_str("\r\n");184 let _ = stream.write_all(out.as_bytes());185 let _ = stream.flush();186 return;187 }188 - respond(&mut stream, 200, ctype, &[], &bytes);189 + respond(&mut stream, 200, ctype, extra, &bytes);190 }191 192 /// Read-and-discard in-flight request bytes (bounded) so early rejects193 diff --git a/tests/raw_safety.rs b/tests/raw_safety.rs194 new file mode 100644195 index 0000000..349a1b5196 --- /dev/null197 +++ b/tests/raw_safety.rs198 @@ -0,0 +1,231 @@199 +//! f0rge #61: `/raw/<path>` must never serve committed bytes as an active200 +//! document. A repo can contain `raw/evil.html`; served as `text/html` it201 +//! runs script in the site's origin (where `/inbox/*` lives).202 +203 +use std::io::{BufRead, BufReader, Read, Write};204 +use std::net::TcpStream;205 +use std::path::{Path, PathBuf};206 +use std::process::{Child, Command, Stdio};207 +use std::sync::atomic::{AtomicU64, Ordering};208 +use std::time::Duration;209 +210 +use std::sync::atomic::AtomicU64 as _A;211 +static SEQ: AtomicU64 = AtomicU64::new(0);212 +const _: fn() = || {213 + let _ = _A::new(0);214 +};215 +216 +fn unique(prefix: &str) -> PathBuf {217 + std::env::temp_dir().join(format!(218 + "hu13-raw-{prefix}-{}-{}",219 + std::process::id(),220 + SEQ.fetch_add(1, Ordering::SeqCst)221 + ))222 +}223 +224 +fn fresh(prefix: &str) -> PathBuf {225 + let p = unique(prefix);226 + std::fs::create_dir_all(&p).unwrap();227 + p228 +}229 +230 +struct Server {231 + child: Child,232 + addr: String,233 + _www: PathBuf,234 + _inbox: PathBuf,235 +}236 +237 +impl Drop for Server {238 + fn drop(&mut self) {239 + self.child.kill().ok();240 + self.child.wait().ok();241 + }242 +}243 +244 +fn start(www: &Path, inbox: &Path) -> Server {245 + let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))246 + .arg("serve")247 + .arg("--www")248 + .arg(www)249 + .arg("--inbox-dir")250 + .arg(inbox)251 + .arg("--listen")252 + .arg("127.0.0.1:0")253 + .stdout(Stdio::piped())254 + .stderr(Stdio::null())255 + .spawn()256 + .expect("spawn hu13 serve");257 + let stdout = child.stdout.take().expect("piped stdout");258 + let mut lines = BufReader::new(stdout).lines();259 + let mut addr = String::new();260 + for _ in 0..50 {261 + if let Some(Ok(line)) = lines.next() {262 + if let Some(rest) = line.split("listening on ").nth(1) {263 + addr = rest.trim().to_string();264 + break;265 + }266 + }267 + }268 + assert!(!addr.is_empty(), "serve printed no listening line");269 + std::thread::sleep(Duration::from_millis(100));270 + Server {271 + child,272 + addr,273 + _www: www.to_path_buf(),274 + _inbox: inbox.to_path_buf(),275 + }276 +}277 +278 +fn get(addr: &str, path: &str) -> String {279 + let mut s = TcpStream::connect(addr).expect("connect");280 + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();281 + write!(282 + s,283 + "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"284 + )285 + .unwrap();286 + let mut out = Vec::new();287 + s.read_to_end(&mut out).unwrap();288 + String::from_utf8_lossy(&out).into_owned()289 +}290 +291 +fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {292 + resp.lines()293 + .find(|l| {294 + l.to_ascii_lowercase()295 + .starts_with(&format!("{}:", name.to_ascii_lowercase()))296 + })297 + .and_then(|l| l.split_once(':'))298 + .map(|(_, v)| v.trim())299 +}300 +301 +#[test]302 +fn raw_active_types_download_never_render() {303 + let www = fresh("www");304 + let inbox = fresh("inbox");305 + for (name, body) in [306 + ("evil.html", "<script>alert(document.domain)</script>"),307 + (308 + "evil.svg",309 + "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",310 + ),311 + ("evil.xml", "<script>alert(1)</script>"),312 + ("evil.xhtml", "<script>alert(1)</script>"),313 + ("note.txt", "plain text"),314 + ("code.rs", "fn main() {}"),315 + ("blob.bin", "\x00\x01binary"),316 + ] {317 + std::fs::create_dir_all(www.join("raw")).unwrap();318 + std::fs::write(www.join("raw").join(name), body).unwrap();319 + }320 + let srv = start(&www, &inbox);321 +322 + for path in [323 + "/raw/evil.html",324 + "/raw/evil.svg",325 + "/raw/evil.xml",326 + "/raw/evil.xhtml",327 + ] {328 + let resp = get(&srv.addr, path);329 + assert!(resp.starts_with("HTTP/1.1 200"), "{path}: {resp}");330 + let ctype = header(&resp, "Content-Type").unwrap_or("");331 + assert!(332 + !ctype.contains("html") && !ctype.contains("svg") && !ctype.contains("xml"),333 + "{path} must not be an active type, got {ctype}"334 + );335 + assert_eq!(ctype, "application/octet-stream", "{path}");336 + let disp = header(&resp, "Content-Disposition").unwrap_or("");337 + assert!(disp.starts_with("attachment"), "{path}: {disp:?}");338 + assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff"));339 + }340 +341 + for path in ["/raw/note.txt", "/raw/code.rs"] {342 + let resp = get(&srv.addr, path);343 + assert_eq!(344 + header(&resp, "Content-Type"),345 + Some("text/plain; charset=utf-8"),346 + "{path}"347 + );348 + assert_eq!(header(&resp, "Content-Disposition"), Some("inline"));349 + }350 +351 + let resp = get(&srv.addr, "/raw/blob.bin");352 + assert_eq!(353 + header(&resp, "Content-Type"),354 + Some("application/octet-stream")355 + );356 + assert_eq!(header(&resp, "Content-Disposition"), Some("attachment"));357 +358 + // HEAD carries the same policy.359 + let mut s = TcpStream::connect(&srv.addr).unwrap();360 + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();361 + write!(362 + s,363 + "HEAD /raw/evil.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"364 + )365 + .unwrap();366 + let mut out = Vec::new();367 + s.read_to_end(&mut out).unwrap();368 + let head = String::from_utf8_lossy(&out);369 + assert_eq!(370 + header(&head, "Content-Disposition"),371 + Some("attachment"),372 + "HEAD: {head}"373 + );374 +}375 +376 +#[test]377 +fn build_emits_pages_headers_file() {378 + let repo = fresh("repo");379 + let out = fresh("out");380 + for args in [381 + ["init", "-b", "main"].as_slice(),382 + &["config", "user.name", "hu13-test"],383 + &["config", "user.email", "[email protected]"],384 + &["config", "commit.gpgsign", "false"],385 + ] {386 + let st = Command::new("git")387 + .arg("-C")388 + .arg(&repo)389 + .args(args)390 + .status()391 + .unwrap();392 + assert!(st.success());393 + }394 + std::fs::write(repo.join("README.md"), "# raw\n").unwrap();395 + std::fs::write(repo.join("evil.html"), "<script>alert(1)</script>").unwrap();396 + let st = Command::new("git")397 + .arg("-C")398 + .arg(&repo)399 + .args(["add", "-A"])400 + .status()401 + .unwrap();402 + assert!(st.success());403 + let st = Command::new("git")404 + .arg("-C")405 + .arg(&repo)406 + .args(["commit", "-m", "raw"])407 + .status()408 + .unwrap();409 + assert!(st.success());410 +411 + let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))412 + .arg("build")413 + .arg(&repo)414 + .arg("-o")415 + .arg(&out)416 + .output()417 + .unwrap();418 + assert!(419 + produced.status.success(),420 + "{}",421 + String::from_utf8_lossy(&produced.stderr)422 + );423 + let headers = std::fs::read_to_string(out.join("_headers")).expect("_headers");424 + assert!(headers.contains("/raw/*"), "{headers}");425 + assert!(426 + headers.contains("Content-Disposition: attachment"),427 + "{headers}"428 + );429 +}430 -- 431 2.56.0432