merge #61 raw XSS + close

54587e4gl0w

all commits

Message

merge #61 raw XSS + close

Diff

.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/comments/76e8678a-b0c4-48c9-996a-8d378f95b233.md+6 −0
diff --git a/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/comments/76e8678a-b0c4-48c9-996a-8d378f95b233.md b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/comments/76e8678a-b0c4-48c9-996a-8d378f95b233.mdnew file mode 100644index 0000000..2d96308--- /dev/null+++ b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/comments/76e8678a-b0c4-48c9-996a-8d378f95b233.md@@ -0,0 +1,6 @@1+---2+id: 76e8678a-b0c4-48c9-996a-8d378f95b2333+author: null4+created: 2026-10-27T15:44:20.861441743Z5+---6+Fixed: serve forces octet-stream+attachment for active extensions (html/svg/xml/xhtml/pdf/...), text/plain inline for text, attachment otherwise; HEAD matches. build emits dist/_headers for Pages with the same raw policy. tests/raw_safety.rs covers both.
.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md+2 −2
diff --git a/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.mdindex 93e34dd..5c477bc 100644--- a/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md+++ b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md@@ -1,13 +1,13 @@11 ---22 id: 4ab82678-f954-4bef-bfbc-eed37ab38bde33 title: 'Stored XSS: raw/<path> serves committed HTML/SVG as text/html'4-state: open4+state: closed55 labels:66 - security77 assignees: []88 milestone: null99 created: 2026-10-27T14:53:32.418656561Z10-updated: null10+updated: 2026-10-27T15:44:20.851821135Z1111 ---1212 `dist/raw/<path>` is byte-identical repo content served with a content type chosen from the *extension*, so any committed `.html` (or `.svg`, `.xhtml`, `.xml`) executes script on the site's origin.1313 
.forge/merges/2026-10-27T15-44-20.792368926Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T15-44-20.792368926Z.md b/.forge/merges/2026-10-27T15-44-20.792368926Z.mdnew file mode 100644index 0000000..798dfd4--- /dev/null+++ b/.forge/merges/2026-10-27T15-44-20.792368926Z.md@@ -0,0 +1,9 @@1+---2+pr: ce18b151-b003-4b58-8d95-258d0c678df13+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T15:44:20.792368926Z7+---8+clean apply9+patch: 5ef4cd4dbb3298782b96ac28f148a49df7d7bfa1
.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/patch.diff+432 −0
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+
.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/pr.md+13 −0
diff --git a/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/pr.md b/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/pr.mdnew file mode 100644index 0000000..041c3ae--- /dev/null+++ b/.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/pr.md@@ -0,0 +1,13 @@1+---2+id: ce18b151-b003-4b58-8d95-258d0c678df13+title: Never serve /raw/ as an active document (#61)4+state: merged5+base: master6+head: issue-61-raw-xss7+draft: false8+issues:9+- 4ab82678-f954-4bef-bfbc-eed37ab38bde10+ci: pass11+created: 2026-10-27T15:43:40.535464991Z12+updated: 2026-10-27T15:44:20.792368926Z13+---