diff --git a/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/patch.diff b/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/patch.diffnew file mode 100644index 0000000..2e20bbe--- /dev/null+++ b/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/patch.diff@@ -0,0 +1,432 @@1+From b1e92d55575b9c122e008521f038c0d24e13f51c 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+