diff --git a/launcher/src/cli/help/registry.md b/launcher/src/cli/help/registry.md index 29d2389..8e6d58d 100644 --- a/launcher/src/cli/help/registry.md +++ b/launcher/src/cli/help/registry.md @@ -16,9 +16,17 @@ service. The contract below is for a tool that reads it directly. DELETE /v1/instances/ a launcher withdrawing itself POST /v1/instances//stop ask that emulator to shut down -Every answer carries permissive cross-origin headers, so a page in a browser -can read the listing. A stop request answers 204 when the registry knows that -port and 404 when it does not. +Any page in a browser can read the listing. Publishing, withdrawing and +stopping require `X-Ark-Registry: 1`. A missing, incorrect or repeated header +answers 403, as does a write carrying `Origin`. Preflight permits GET and +OPTIONS and never allows the write header. The fixed value prevents browser +writes; it does not authenticate local processes. A declared write body larger +than 8 KiB answers 413 before these checks. + +A stop request answers 204 when the registry knows that port and 404 when it +does not. Older launchers without the header cannot publish to a current +registry host. Restart all running launchers after updating, since an older +host still accepts unguarded writes. ## The listing diff --git a/launcher/src/ipc/discovery.rs b/launcher/src/ipc/discovery.rs index c3fb6fb..8771d47 100644 --- a/launcher/src/ipc/discovery.rs +++ b/launcher/src/ipc/discovery.rs @@ -70,7 +70,7 @@ fn registry_addr() -> SocketAddrV4 { /// this build does not know, a host that stops answering, is reported, since /// something is there and it is not a registry this build can trust. pub(crate) fn list() -> Result> { - let body = match request("GET", "/v1/instances", None) { + let body = match request(registry_addr(), "GET", "/v1/instances", None) { Ok(body) => body, Err(Refused) => return Ok(Vec::new()), Err(Failed(err)) => return Err(err), @@ -124,7 +124,12 @@ pub(crate) fn answering(port: u16) -> bool { /// Ask the emulator on `port` to shut down. The request waits in the registry /// until that emulator's next heartbeat collects it. pub(crate) fn request_stop(port: u16) -> Result<()> { - request("POST", &format!("/v1/instances/{port}/stop"), None)?; + request( + registry_addr(), + "POST", + &format!("/v1/instances/{port}/stop"), + None, + )?; Ok(()) } @@ -190,7 +195,7 @@ fn publish(hardware: &Controller) { } }; - if let Ok(answer) = request("POST", "/v1/instances", Some(&body)) { + if let Ok(answer) = request(registry_addr(), "POST", "/v1/instances", Some(&body)) { drop(publishing); obey(&answer); return; @@ -201,7 +206,7 @@ fn publish(hardware: &Controller) { if registry::host() { log!("[discovery] the registry had no host, taking it over"); } - let answer = request("POST", "/v1/instances", Some(&body)); + let answer = request(registry_addr(), "POST", "/v1/instances", Some(&body)); drop(publishing); match answer { Ok(answer) => obey(&answer), @@ -230,7 +235,12 @@ pub(crate) fn deregister() { return; }; let port = entry.lock().unwrap().port; - if let Err(e) = request("DELETE", &format!("/v1/instances/{port}"), None) { + if let Err(e) = request( + registry_addr(), + "DELETE", + &format!("/v1/instances/{port}"), + None, + ) { log!("[discovery] could not deregister: {e}"); } } @@ -310,8 +320,12 @@ impl From for anyhow::Error { /// for. Spoken as HTTP/1.0, so the answer ends at end of file and carries no /// chunked framing, and bounded, so a server that is not a registry cannot /// feed this forever. -fn request(method: &str, path: &str, body: Option<&[u8]>) -> Result, Unanswered> { - let addr = registry_addr(); +fn request( + addr: SocketAddrV4, + method: &str, + path: &str, + body: Option<&[u8]>, +) -> Result, Unanswered> { let mut stream = TcpStream::connect_timeout(&addr.into(), TIMEOUT).map_err(|_| Refused)?; let mut exchange = || -> Result> { stream.set_read_timeout(Some(TIMEOUT))?; @@ -325,6 +339,14 @@ fn request(method: &str, path: &str, body: Option<&[u8]>) -> Result, Una if body.is_some() { head.push_str("Content-Type: application/json\r\n"); } + if matches!(method, "POST" | "DELETE") { + write!( + head, + "{}: {}\r\n", + registry::WRITE_HEADER, + registry::WRITE_HEADER_VALUE + )?; + } head.push_str("\r\n"); stream.write_all(head.as_bytes())?; @@ -373,6 +395,63 @@ fn split_response(raw: &[u8]) -> Result> { mod tests { use super::*; + /// Native clients send the browser guard for every write and leave reads open. + #[test] + fn test_registry_requests_carry_the_write_guard() { + // Inspect actual HTTP requests on a private port without a running emulator + let server = tiny_http::Server::http((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + server.server_addr().to_ip().unwrap().port(), + ); + let worker = thread::spawn(move || { + for method in ["GET", "POST", "POST", "DELETE"] { + let incoming = server + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + assert_eq!(incoming.method().as_str(), method); + let guards: Vec<_> = incoming + .headers() + .iter() + .filter(|header| header.field.equiv("X-Ark-Registry")) + .map(|header| header.value.as_str()) + .collect(); + if method == "GET" { + assert!(guards.is_empty()); + } else { + assert_eq!(guards, ["1"]); + } + assert!( + !incoming + .headers() + .iter() + .any(|header| header.field.equiv("Origin")) + ); + incoming.respond(tiny_http::Response::empty(204)).unwrap(); + } + }); + + // Cover listing, publication, stop and withdrawal through the shared client + for (method, path, body) in [ + ("GET", "/v1/instances", None), + ( + "POST", + "/v1/instances", + Some( + br#"{"port":18181,"disk":"demo.ark","disk_id":"0123abcd","ready":true}"# + .as_slice(), + ), + ), + ("POST", "/v1/instances/18181/stop", None), + ("DELETE", "/v1/instances/18181", None), + ] { + request(address, method, path, body) + .unwrap_or_else(|err| panic!("{method} {path}: {err}")); + } + worker.join().unwrap(); + } + #[test] fn test_a_body_is_split_off_the_headers() { let raw = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"a\":1}"; diff --git a/launcher/src/ipc/registry.rs b/launcher/src/ipc/registry.rs index 435af79..86a7a00 100644 --- a/launcher/src/ipc/registry.rs +++ b/launcher/src/ipc/registry.rs @@ -31,6 +31,10 @@ //! here has to be `*`, so the registry publishes a disk image's file name but //! never its path. //! +//! Writes require a fixed header that CORS preflights never allow, and reject +//! browser origins. This keeps web pages from changing the registry while +//! local processes can still publish, withdraw and stop emulators. +//! //! The registry is also the mailbox a shutdown travels through. A request to //! stop an emulator is recorded against its entry, and the next heartbeat from //! that launcher is answered with it. Nothing here signals, reaches for a pid @@ -55,6 +59,12 @@ pub(crate) const REGISTRY_PORT: u16 = 18180; /// registry can tell rather than guess. Bumped only for a breaking change. pub(crate) const SCHEMA_VERSION: u32 = 1; +/// Header required on registry writes and excluded from CORS preflight grants. +pub(super) const WRITE_HEADER: &str = "X-Ark-Registry"; + +/// Fixed browser guard, with no authentication of local processes. +pub(super) const WRITE_HEADER_VALUE: &str = "1"; + /// How long an entry survives without being refreshed. Comfortably more than /// the heartbeat interval in [`super::discovery`], so a launcher that is busy /// or beating slowly is not dropped between two of its beats. @@ -271,6 +281,39 @@ fn handle(mut request: Request, registry: &mut Registry) { let url = request.url().to_string(); let path = url.split('?').next().unwrap_or("").to_string(); + // Refuse browser writes before parsing a body or changing registry state + if matches!(method, Method::Post | Method::Delete) { + let has_write_header = request + .headers() + .iter() + .filter(|header| header.field.equiv(WRITE_HEADER)) + .map(|header| header.value.as_str()) + .eq([WRITE_HEADER_VALUE]); + let refusal = if request + .body_length() + .is_some_and(|length| length > MAX_BODY) + { + Some(text(StatusCode(413), "body too large")) + } else if request + .headers() + .iter() + .any(|header| header.field.equiv("Origin")) + { + Some(text(StatusCode(403), "browser writes are not accepted")) + } else if !has_write_header { + Some(text( + StatusCode(403), + &format!("registry writes require {WRITE_HEADER}: {WRITE_HEADER_VALUE}"), + )) + } else { + None + }; + if let Some(response) = refusal { + respond(request, response); + return; + } + } + let response = match (&method, path.as_str()) { // Preflight, answered for any path so a consumer probing a route this // build does not have can still read the 404. @@ -353,6 +396,7 @@ fn read_body(request: &mut Request) -> Result, Response>> /// The allowed origin is `*` because an allowlist would mean baking somebody's /// hostnames in here. What keeps that acceptable is the shape of an /// [`Instance`]: ports and file names, never paths. +/// Writes need [`WRITE_HEADER`], which this preflight never permits. /// /// `Access-Control-Allow-Private-Network` is for Chromium's private network /// access rules, under which a page on a public origin reaching a loopback @@ -360,7 +404,7 @@ fn read_body(request: &mut Request) -> Result, Response>> fn cors() -> Vec
{ [ ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), + ("Access-Control-Allow-Methods", "GET, OPTIONS"), ("Access-Control-Allow-Headers", "Content-Type"), ("Access-Control-Allow-Private-Network", "true"), ("Access-Control-Max-Age", "600"), @@ -413,10 +457,289 @@ fn respond(request: Request, mut response: Response>>) { let _ = request.respond(response); } +/// Registry lifecycle and HTTP browser guards. #[cfg(test)] mod tests { + use std::io::Write as _; + use std::net::TcpStream; + use super::*; + /// A registration body using the public registry contract. + const INSTANCE: &str = r#"{"port":18181,"disk":"demo.ark","disk_id":"0123abcd","ready":true}"#; + + /// An HTTP reply received through the registry's real request handler. + struct Reply { + /// HTTP status returned by the handler. + status: u16, + /// Response headers indexed by lowercase name. + headers: HashMap, + /// Unencoded response body. + body: String, + } + + /// Send a raw HTTP request through an isolated registry listener. + fn exchange( + registry: &mut Registry, + method: &str, + path: &str, + headers: &str, + body: &str, + ) -> Reply { + // Use a private port so these tests cannot touch a running emulator + let server = Server::http((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = server.server_addr().to_ip().unwrap(); + let timeout = Duration::from_secs(2); + let mut stream = TcpStream::connect_timeout(&address, timeout).unwrap(); + stream.set_read_timeout(Some(timeout)).unwrap(); + stream.set_write_timeout(Some(timeout)).unwrap(); + write!( + stream, + "{method} {path} HTTP/1.0\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", + body.len() + ) + .unwrap(); + + // Exercise routing and header parsing before inspecting the reply + let request = server.recv_timeout(timeout).unwrap().unwrap(); + handle(request, registry); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (head, body) = response.split_once("\r\n\r\n").unwrap(); + let mut lines = head.lines(); + let status = lines.next().unwrap().split_whitespace().nth(1).unwrap(); + Reply { + status: status.parse().unwrap(), + headers: lines + .map(|line| { + let (name, value) = line.split_once(':').unwrap(); + (name.to_ascii_lowercase(), value.trim().to_owned()) + }) + .collect(), + body: body.to_owned(), + } + } + + /// Missing guards and browser origins cannot publish, withdraw or queue stops. + #[test] + fn test_rejected_writes_preserve_entries_and_pending_stops() { + // Publish through HTTP so every rejected request starts from a real entry + let mut registry = Registry::new(); + let allowed = "X-Ark-Registry: 1\r\n"; + assert_eq!( + exchange(&mut registry, "POST", "/v1/instances", allowed, INSTANCE).status, + 204 + ); + let before = exchange(&mut registry, "GET", "/v1/instances", "", "").body; + + // Check each write, including simple POSTs that need no browser preflight + for (case, headers) in [ + ("missing", ""), + ("wrong", "X-Ark-Registry: 0\r\n"), + ("empty", "X-Ark-Registry:\r\n"), + ("repeated", "X-Ark-Registry: 1\r\nx-ark-registry: 1\r\n"), + ("origin only", "Origin: https://example.com\r\n"), + ( + "origin", + "X-Ark-Registry: 1\r\noRiGiN: https://example.com\r\n", + ), + ("null origin", "X-Ark-Registry: 1\r\nOrigin: null\r\n"), + ("empty origin", "X-Ark-Registry: 1\r\nOrigin:\r\n"), + ] { + for (method, path, body) in [ + ( + "POST", + "/v1/instances", + r#"{"port":18181,"disk":"forged.ark","disk_id":"ffff","ready":false}"#, + ), + ( + "POST", + "/v1/instances", + r#"{"port":18182,"disk":"extra.ark","disk_id":"ffff","ready":false}"#, + ), + ("POST", "/v1/instances", "not json"), + ("POST", "/v1/instances/18181/stop", ""), + ("DELETE", "/v1/instances/18181", ""), + ] { + let reply = exchange(&mut registry, method, path, headers, body); + assert_eq!(reply.status, 403, "{case} {method} {path} {body}"); + assert_eq!( + exchange(&mut registry, "GET", "/v1/instances", "", "").body, + before, + "{case} {method} {path} {body}" + ); + assert_eq!( + exchange(&mut registry, "POST", "/v1/instances", allowed, INSTANCE).status, + 204, + "{case} {method} {path} {body}" + ); + } + } + } + + /// Oversized write bodies fail before the header guard is checked. + #[test] + fn test_write_size_limit_precedes_the_browser_guard() { + let mut registry = Registry::new(); + let reply = exchange( + &mut registry, + "POST", + "/v1/instances", + "", + &"x".repeat(8193), + ); + assert_eq!(reply.status, 413); + let listing = exchange(&mut registry, "GET", "/v1/instances", "", ""); + assert_eq!( + serde_json::from_str::(&listing.body).unwrap(), + serde_json::json!({"version": 1, "instances": []}) + ); + } + + /// Guarded native writes still publish, refresh, deliver stops and withdraw. + #[test] + fn test_guarded_writes_keep_the_registry_lifecycle() { + // Header names are case-insensitive on every mutating route + let mut registry = Registry::new(); + let allowed = "x-aRk-ReGiStRy: 1\r\n"; + assert_eq!( + exchange(&mut registry, "POST", "/v1/instances", allowed, INSTANCE).status, + 204 + ); + let updated = r#"{"port":18181,"disk":"renamed.ark","disk_id":"0123abcd","ready":false}"#; + assert_eq!( + exchange(&mut registry, "POST", "/v1/instances", allowed, updated).status, + 204 + ); + let listing = exchange(&mut registry, "GET", "/v1/instances", "", ""); + let body: serde_json::Value = serde_json::from_str(&listing.body).unwrap(); + assert_eq!(body["instances"][0]["disk"], "renamed.ark"); + assert_eq!(body["instances"][0]["ready"], false); + + // A stop is queued only for a known entry and reaches its next heartbeat + assert_eq!( + exchange( + &mut registry, + "POST", + "/v1/instances/18182/stop", + allowed, + "" + ) + .status, + 404 + ); + assert_eq!( + exchange( + &mut registry, + "POST", + "/v1/instances/18181/stop", + allowed, + "" + ) + .status, + 204 + ); + let beat = exchange(&mut registry, "POST", "/v1/instances", allowed, updated); + assert_eq!(beat.status, 200); + assert_eq!( + serde_json::from_str::(&beat.body).unwrap(), + serde_json::json!({"stop": true}) + ); + + // Withdrawal remains idempotent and removes the pending stop with the entry + for _ in 0..2 { + assert_eq!( + exchange(&mut registry, "DELETE", "/v1/instances/18181", allowed, "").status, + 204 + ); + } + let listing = exchange(&mut registry, "GET", "/v1/instances", "", ""); + assert_eq!( + serde_json::from_str::(&listing.body).unwrap(), + serde_json::json!({"version": 1, "instances": []}) + ); + assert_eq!( + exchange(&mut registry, "POST", "/v1/instances", allowed, INSTANCE).status, + 204 + ); + } + + /// Browser reads stay open while preflight never grants the write header. + #[test] + fn test_browser_reads_and_preflights_never_authorize_writes() { + // Read a populated registry with an ordinary browser origin + let mut registry = Registry::new(); + assert_eq!( + exchange( + &mut registry, + "POST", + "/v1/instances", + "X-Ark-Registry: 1\r\n", + INSTANCE + ) + .status, + 204 + ); + let listing = exchange( + &mut registry, + "GET", + "/v1/instances", + "Origin: https://example.com\r\n", + "", + ); + assert_eq!(listing.status, 200); + assert_eq!(listing.headers["access-control-allow-origin"], "*"); + let body: serde_json::Value = serde_json::from_str(&listing.body).unwrap(); + assert_eq!(body["version"], 1); + assert_eq!(body["instances"][0]["port"], 18181); + + // Requested methods and headers never get reflected into the grant + for (method, path, requested_headers) in [ + ("GET", "/v1/instances", ""), + ("POST", "/v1/instances", "content-type, x-ark-registry"), + ("POST", "/v1/instances/18181/stop", "x-ark-registry"), + ("DELETE", "/v1/instances/18181", "X-Ark-Registry"), + ("POST", "/unknown", "x-ark-registry"), + ] { + let headers = format!( + "Origin: https://example.com\r\nAccess-Control-Request-Method: {method}\r\nAccess-Control-Request-Headers: {requested_headers}\r\nAccess-Control-Request-Private-Network: true\r\n" + ); + let reply = exchange(&mut registry, "OPTIONS", path, &headers, ""); + assert_eq!(reply.status, 204, "{method} {path}"); + assert_eq!( + reply.headers["access-control-allow-origin"], "*", + "{method} {path}" + ); + assert_eq!( + reply.headers["access-control-allow-methods"], "GET, OPTIONS", + "{method} {path}" + ); + assert_eq!( + reply.headers["access-control-allow-headers"], "Content-Type", + "{method} {path}" + ); + assert_eq!( + reply.headers["access-control-allow-private-network"], "true", + "{method} {path}" + ); + } + assert_eq!( + exchange(&mut registry, "GET", "/v1/instances", "", "").body, + listing.body + ); + assert_eq!( + exchange( + &mut registry, + "POST", + "/v1/instances", + "X-Ark-Registry: 1\r\n", + INSTANCE + ) + .status, + 204 + ); + } + fn instance(port: u16) -> Instance { Instance { port, @@ -556,16 +879,6 @@ mod tests { assert!(!String::from_utf8(encoded).unwrap().contains("expiry")); } - #[test] - fn test_the_preflight_headers_cover_private_network_access() { - let names: Vec = cors() - .iter() - .map(|header| header.field.as_str().as_str().to_ascii_lowercase()) - .collect(); - assert!(names.contains(&"access-control-allow-origin".to_string())); - assert!(names.contains(&"access-control-allow-private-network".to_string())); - } - #[test] fn test_hosting_twice_from_one_process_is_refused() { // The second call is the one a heartbeat makes after a failed publish.