1 //! f0rge #61: `/raw/<path>` must never serve committed bytes as an active2 //! document. A repo can contain `raw/evil.html`; served as `text/html` it3 //! runs script in the site's origin (where `/inbox/*` lives).4 5 use std::io::{BufRead, BufReader, Read, Write};6 use std::net::TcpStream;7 use std::path::{Path, PathBuf};8 use std::process::{Child, Command, Stdio};9 use std::sync::atomic::{AtomicU64, Ordering};10 use std::time::Duration;11 12 use std::sync::atomic::AtomicU64 as _A;13 static SEQ: AtomicU64 = AtomicU64::new(0);14 const _: fn() = || {15 let _ = _A::new(0);16 };17 18 fn unique(prefix: &str) -> PathBuf {19 std::env::temp_dir().join(format!(20 "hu13-raw-{prefix}-{}-{}",21 std::process::id(),22 SEQ.fetch_add(1, Ordering::SeqCst)23 ))24 }25 26 fn fresh(prefix: &str) -> PathBuf {27 let p = unique(prefix);28 std::fs::create_dir_all(&p).unwrap();29 p30 }31 32 struct Server {33 child: Child,34 addr: String,35 _www: PathBuf,36 _inbox: PathBuf,37 }38 39 impl Drop for Server {40 fn drop(&mut self) {41 self.child.kill().ok();42 self.child.wait().ok();43 }44 }45 46 fn start(www: &Path, inbox: &Path) -> Server {47 let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))48 .arg("serve")49 .arg("--www")50 .arg(www)51 .arg("--inbox-dir")52 .arg(inbox)53 .arg("--listen")54 .arg("127.0.0.1:0")55 .stdout(Stdio::piped())56 .stderr(Stdio::null())57 .spawn()58 .expect("spawn hu13 serve");59 let stdout = child.stdout.take().expect("piped stdout");60 let mut lines = BufReader::new(stdout).lines();61 let mut addr = String::new();62 for _ in 0..50 {63 if let Some(Ok(line)) = lines.next() {64 if let Some(rest) = line.split("listening on ").nth(1) {65 addr = rest.trim().to_string();66 break;67 }68 }69 }70 assert!(!addr.is_empty(), "serve printed no listening line");71 std::thread::sleep(Duration::from_millis(100));72 Server {73 child,74 addr,75 _www: www.to_path_buf(),76 _inbox: inbox.to_path_buf(),77 }78 }79 80 fn get(addr: &str, path: &str) -> String {81 let mut s = TcpStream::connect(addr).expect("connect");82 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();83 write!(84 s,85 "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"86 )87 .unwrap();88 let mut out = Vec::new();89 s.read_to_end(&mut out).unwrap();90 String::from_utf8_lossy(&out).into_owned()91 }92 93 fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {94 resp.lines()95 .find(|l| {96 l.to_ascii_lowercase()97 .starts_with(&format!("{}:", name.to_ascii_lowercase()))98 })99 .and_then(|l| l.split_once(':'))100 .map(|(_, v)| v.trim())101 }102 103 #[test]104 fn raw_active_types_download_never_render() {105 let www = fresh("www");106 let inbox = fresh("inbox");107 for (name, body) in [108 ("evil.html", "<script>alert(document.domain)</script>"),109 (110 "evil.svg",111 "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",112 ),113 ("evil.xml", "<script>alert(1)</script>"),114 ("evil.xhtml", "<script>alert(1)</script>"),115 ("note.txt", "plain text"),116 ("code.rs", "fn main() {}"),117 ("blob.bin", "\x00\x01binary"),118 ] {119 std::fs::create_dir_all(www.join("raw")).unwrap();120 std::fs::write(www.join("raw").join(name), body).unwrap();121 }122 let srv = start(&www, &inbox);123 124 for path in [125 "/raw/evil.html",126 "/raw/evil.svg",127 "/raw/evil.xml",128 "/raw/evil.xhtml",129 ] {130 let resp = get(&srv.addr, path);131 assert!(resp.starts_with("HTTP/1.1 200"), "{path}: {resp}");132 let ctype = header(&resp, "Content-Type").unwrap_or("");133 assert!(134 !ctype.contains("html") && !ctype.contains("svg") && !ctype.contains("xml"),135 "{path} must not be an active type, got {ctype}"136 );137 assert_eq!(ctype, "application/octet-stream", "{path}");138 let disp = header(&resp, "Content-Disposition").unwrap_or("");139 assert!(disp.starts_with("attachment"), "{path}: {disp:?}");140 assert_eq!(header(&resp, "X-Content-Type-Options"), Some("nosniff"));141 }142 143 for path in ["/raw/note.txt", "/raw/code.rs"] {144 let resp = get(&srv.addr, path);145 assert_eq!(146 header(&resp, "Content-Type"),147 Some("text/plain; charset=utf-8"),148 "{path}"149 );150 assert_eq!(151 header(&resp, "Content-Disposition"),152 Some("attachment"),153 "{path} downloads, matching _headers"154 );155 }156 157 let resp = get(&srv.addr, "/raw/blob.bin");158 assert_eq!(159 header(&resp, "Content-Type"),160 Some("application/octet-stream")161 );162 assert_eq!(header(&resp, "Content-Disposition"), Some("attachment"));163 164 // HEAD carries the same policy.165 let mut s = TcpStream::connect(&srv.addr).unwrap();166 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();167 write!(168 s,169 "HEAD /raw/evil.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"170 )171 .unwrap();172 let mut out = Vec::new();173 s.read_to_end(&mut out).unwrap();174 let head = String::from_utf8_lossy(&out);175 assert_eq!(176 header(&head, "Content-Disposition"),177 Some("attachment"),178 "HEAD: {head}"179 );180 }181 182 #[test]183 fn build_emits_pages_headers_file() {184 let repo = fresh("repo");185 let out = fresh("out");186 for args in [187 ["init", "-b", "main"].as_slice(),188 &["config", "user.name", "hu13-test"],189 &["config", "user.email", "[email protected]"],190 &["config", "commit.gpgsign", "false"],191 ] {192 let st = Command::new("git")193 .arg("-C")194 .arg(&repo)195 .args(args)196 .status()197 .unwrap();198 assert!(st.success());199 }200 std::fs::write(repo.join("README.md"), "# raw\n").unwrap();201 std::fs::write(repo.join("evil.html"), "<script>alert(1)</script>").unwrap();202 let st = Command::new("git")203 .arg("-C")204 .arg(&repo)205 .args(["add", "-A"])206 .status()207 .unwrap();208 assert!(st.success());209 let st = Command::new("git")210 .arg("-C")211 .arg(&repo)212 .args(["commit", "-m", "raw"])213 .status()214 .unwrap();215 assert!(st.success());216 217 let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))218 .arg("build")219 .arg(&repo)220 .arg("-o")221 .arg(&out)222 .output()223 .unwrap();224 assert!(225 produced.status.success(),226 "{}",227 String::from_utf8_lossy(&produced.stderr)228 );229 let headers = std::fs::read_to_string(out.join("_headers")).expect("_headers");230 assert!(headers.contains("/raw/*"), "{headers}");231 assert!(232 headers.contains("Content-Disposition: attachment"),233 "{headers}"234 );235 }