diff --git a/crates/platform/src/serve.rs b/crates/platform/src/serve.rs index 45a4eb40..ad7524d4 100644 --- a/crates/platform/src/serve.rs +++ b/crates/platform/src/serve.rs @@ -134,9 +134,26 @@ fn handle(mut stream: TcpStream, root: &Path) -> std::io::Result<()> { reader.read_line(&mut line)?; // Drain the headers (bounded by `take`). let mut h = String::new(); + let (mut hosts, mut origins) = (Vec::new(), Vec::new()); while reader.read_line(&mut h)? > 2 { + if let Some((name, value)) = h.trim_end_matches(['\r', '\n']).split_once(':') { + if name.eq_ignore_ascii_case("host") { + hosts.push(value.trim().to_string()); + } + if name.eq_ignore_ascii_case("origin") { + origins.push(value.trim().to_string()); + } + } h.clear(); } + if hosts.len() != 1 || origins.len() > 1 { + return stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + if !hosts.first().is_some_and(|h| crate::standin_server::host_is_loopback(h, Some(stream.local_addr().map_or(0, |a| a.port())))) + || origins.first().is_some_and(|o| !crate::standin_server::origin_is_local(o)) + { + return stream.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } let mut parts = line.split_whitespace(); let (method, target) = (parts.next().unwrap_or_default(), parts.next().unwrap_or("/")); if method != "GET" && method != "HEAD" { @@ -176,7 +193,7 @@ mod tests { fn get(port: u16, path: &str) -> String { let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); - s.write_all(format!("GET {path} HTTP/1.1\r\nHost: x\r\n\r\n").as_bytes()).unwrap(); + s.write_all(format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n").as_bytes()).unwrap(); let mut out = String::new(); s.read_to_string(&mut out).unwrap(); out diff --git a/crates/platform/tests/regression_preview_local_host.rs b/crates/platform/tests/regression_preview_local_host.rs new file mode 100644 index 00000000..7d0bd4ba --- /dev/null +++ b/crates/platform/tests/regression_preview_local_host.rs @@ -0,0 +1,50 @@ +use craft_anything_platform::serve::serve_dir; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; +fn get(port: u16, path: &str) -> String { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.write_all(format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n").as_bytes()).unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + out +} + +#[test] +fn regression_preview_refuses_foreign_hosts_and_origins() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.html"), "local-only-content").unwrap(); + let srv = serve_dir(dir.path()).unwrap(); + for extra in [ + "Host: attacker.example\r\n".to_string(), + "Host: localhost.attacker.example\r\n".to_string(), + format!("Host: localhost:{}\r\nOrigin: https://attacker.example\r\n", srv.port), + format!("Host: localhost:{}\r\nHost: attacker.example\r\n", srv.port), + format!("Host: localhost:{}\r\nOrigin: http://localhost:3000\r\nOrigin: https://attacker.example\r\n", srv.port), + ] { + let mut s = TcpStream::connect(("127.0.0.1", srv.port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.write_all(format!("GET / HTTP/1.1\r\n{extra}\r\n").as_bytes()).unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + assert!(out.starts_with("HTTP/1.1 400") || out.starts_with("HTTP/1.1 403"), "{out}"); + assert!(!out.contains("local-only-content")); + } + assert!(get(srv.port, "/").contains("local-only-content")); +} + +#[test] +fn regression_preview_accepts_a_local_origin() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.html"), "local-only-content").unwrap(); + let srv = serve_dir(dir.path()).unwrap(); + let mut stream = TcpStream::connect(("127.0.0.1", srv.port)).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + stream.write_all(format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nOrigin: http://localhost:3000\r\n\r\n", srv.port).as_bytes()).unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (headers, body) = response.split_once("\r\n\r\n").unwrap(); + assert!(headers.starts_with("HTTP/1.1 200 "), "{headers}"); + assert_eq!(body, "local-only-content"); +}