tests/raw_safety.rs 6.8 KiB 235 lines Raw
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 }