.forge/prs/ce18b151-b003-4b58-8d95-258d0c678df1/patch.diff 13.5 KiB 432 lines Raw
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