diff --git a/Cargo.lock b/Cargo.lock index 67e357bf..e1419a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,6 +1409,7 @@ dependencies = [ "flate2", "futures-util", "goblin", + "http", "http-body-util", "hyper", "hyper-rustls", diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 00fa7838..1af12803 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -639,14 +639,14 @@ pub async fn run(args: LearnArgs) -> Result<()> { #[cfg(feature = "http")] { let http_requests_cb = Arc::clone(&observer.http_requests); - let http_log: Arc = - Arc::new(move |method, host, path| { - http_requests_cb - .lock() - .unwrap() - .insert(format!("{method} {host}{path}")); - }); - builder = builder.http_log_fn(http_log); + let http_observer: sandlock_core::http::HttpObserver = Arc::new(move |event| { + http_requests_cb + .lock() + .unwrap() + .insert(format!("{} {}{}", event.method, event.host, event.path)); + sandlock_core::http::HttpSinks::default() + }); + builder = builder.http_observer(http_observer); } for path in &args.http_inject_ca { builder = builder.http_inject_ca(path); diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 21d9275b..59ec28a3 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -16,6 +16,7 @@ syscalls = { version = "0.8", default-features = false } nix = { version = "0.29", features = ["dir", "user"] } tokio = { version = "1.53.3", features = ["rt", "net", "time", "sync", "macros", "io-util", "fs"] } tokio-util = "0.7" +http = "1" serde = { version = "1", features = ["derive"] } thiserror = "2" rand = "0.8" diff --git a/crates/sandlock-core/src/http.rs b/crates/sandlock-core/src/http.rs index 12be3398..526e061b 100644 --- a/crates/sandlock-core/src/http.rs +++ b/crates/sandlock-core/src/http.rs @@ -3,6 +3,47 @@ use serde::{Deserialize, Serialize}; use crate::error::SandboxError; use crate::network::{NetAllow, NetTarget, Protocol}; +/// Re-exported so an observer can name the type without depending on `http`. +pub use ::http::HeaderMap; + +/// One request observed by the proxy, borrowed for the duration of the call. +pub struct HttpRequestEvent<'a> { + pub method: &'a str, + /// Validated authority, never the raw `Host` header. + pub host: &'a str, + pub path: &'a str, + pub headers: &'a ::http::HeaderMap, + pub allowed: bool, +} + +/// One upstream response, borrowed for the duration of the call. +pub struct HttpResponseEvent<'a> { + pub status: u16, + pub headers: &'a ::http::HeaderMap, +} + +/// Receives one body as it streams, in either direction. +pub type HttpBodySink = Box; + +/// Called once, when the response status and headers exist, which is later than +/// the observer runs. Returns a sink to tee that response's body, or `None`. +pub type HttpResponseHook = Box Option + Send>; + +/// What an observer asks for. Both sinks are chosen while the request is still +/// in scope, so a consumer correlates the two bodies by what it captures. +#[derive(Default)] +pub struct HttpSinks { + /// `Some` tees the request body, `None` leaves it untouched. + pub request: Option, + /// `Some` is called with the upstream response, and may then tee its body. + /// A denied request has no upstream response, so this is never called; the + /// child still receives sandlock's 403. + pub response: Option, +} + +/// Observes every request reaching the proxy. +pub type HttpObserver = std::sync::Arc HttpSinks + Send + Sync>; + /// An HTTP access control rule. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HttpRule { diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 8db65cf1..adce8b34 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -807,10 +807,10 @@ pub struct Sandbox { /// Path to write the active MITM CA public cert (PEM) for external trust /// wiring (e.g. NODE_EXTRA_CA_CERTS). Never writes the private key. pub http_ca_out: Option, - /// Optional observation callback for HTTP learn mode. When set the proxy is - /// spawned even without ACL rules; every request is logged via this closure. + /// Optional observation callback. When set the proxy is spawned even + /// without ACL rules. #[serde(skip)] - pub(crate) http_log_fn: Option>, + pub(crate) http_observer: Option, // Resource limits pub max_memory: Option, @@ -973,7 +973,7 @@ impl Clone for Sandbox { http_key: self.http_key.clone(), http_inject_ca: self.http_inject_ca.clone(), http_ca_out: self.http_ca_out.clone(), - http_log_fn: self.http_log_fn.clone(), + http_observer: self.http_observer.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -2440,7 +2440,8 @@ impl Sandbox { ); let mut ca_inject_pem: Option>> = None; - if !self.http_allow.is_empty() || !self.http_deny.is_empty() || self.http_log_fn.is_some() { + if !self.http_allow.is_empty() || !self.http_deny.is_empty() || self.http_observer.is_some() + { // Generate an ephemeral CA when injection is requested without BYO. let generate = !self.http_inject_ca.is_empty(); let ca_material = crate::transparent_proxy::resolve_ca( @@ -2473,7 +2474,7 @@ impl Sandbox { std::sync::Arc::clone(&self.inject), cert_pem, key_pem, - self.http_log_fn.clone(), + self.http_observer.clone(), ) .await .map_err(SandboxRuntimeError::Io)?; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 8fbfb975..364ecde3 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -115,10 +115,10 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "http-ca-out", value_name = "PATH"))] pub http_ca_out: Option, - /// Optional observation callback for HTTP learn mode. When set, the proxy is - /// spawned even without ACL rules so every request is logged via this closure. + /// Optional observation callback. When set, the proxy is spawned even + /// without ACL rules so every request reaching it is observed. #[cfg_attr(feature = "cli", clap(skip))] - pub http_log_fn: Option>, + pub http_observer: Option, // max_memory uses a string in the CLI (e.g. "512M"); not directly clap-friendly as ByteSize. #[cfg_attr(feature = "cli", clap(skip))] @@ -295,7 +295,7 @@ impl Default for SandboxBuilder { http_key: None, http_inject_ca: Vec::new(), http_ca_out: None, - http_log_fn: None, + http_observer: None, max_memory: None, max_processes: None, max_open_files: None, @@ -362,7 +362,7 @@ impl Clone for SandboxBuilder { http_key: self.http_key.clone(), http_inject_ca: self.http_inject_ca.clone(), http_ca_out: self.http_ca_out.clone(), - http_log_fn: self.http_log_fn.clone(), + http_observer: self.http_observer.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -607,11 +607,12 @@ impl SandboxBuilder { self } - pub fn http_log_fn( - mut self, - f: std::sync::Arc, - ) -> Self { - self.http_log_fn = Some(f); + /// Observe every request that reaches the proxy, allowed or denied. + /// + /// The callback returns [`Some`] to receive that request's body as it + /// streams to the upstream, or [`None`] to skip it. + pub fn http_observer(mut self, f: crate::http::HttpObserver) -> Self { + self.http_observer = Some(f); self } @@ -1084,7 +1085,7 @@ impl SandboxBuilder { ("--http-ca-out", self.http_ca_out.is_some()), ("--http-auth", !self.http_auth.is_empty()), ("--credential", !self.credentials.is_empty()), - ("http_log_fn", self.http_log_fn.is_some()), + ("http_observer", self.http_observer.is_some()), ]; if let Some((what, _)) = set.iter().find(|(_, on)| *on) { return Err(SandboxError::FeatureDisabled { @@ -1102,12 +1103,15 @@ impl SandboxBuilder { } // --http-inject-ca / --http-ca-out are meaningless without an HTTP ACL - // proxy to do MITM, which only spawns when http rules or a log callback exist. - let has_http_rules = - !self.http_allow.is_empty() || !self.http_deny.is_empty() || self.http_log_fn.is_some(); + // proxy to do MITM, which only spawns when http rules or an observation + // callback exist. + let has_http_rules = !self.http_allow.is_empty() + || !self.http_deny.is_empty() + || self.http_observer.is_some(); if !self.http_inject_ca.is_empty() && !has_http_rules { return Err(SandboxError::Invalid( - "--http-inject-ca requires --http-allow or --http-deny".into(), + "--http-inject-ca requires --http-allow, --http-deny, or an observation callback" + .into(), )); } // --http-ca-out needs an actual CA to export (BYO or generated). @@ -1269,7 +1273,7 @@ impl SandboxBuilder { http_key: self.http_key, http_inject_ca: self.http_inject_ca, http_ca_out: self.http_ca_out, - http_log_fn: self.http_log_fn, + http_observer: self.http_observer, max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, diff --git a/crates/sandlock-core/src/transparent_proxy/disabled.rs b/crates/sandlock-core/src/transparent_proxy/disabled.rs index b0d92828..1f63cc52 100644 --- a/crates/sandlock-core/src/transparent_proxy/disabled.rs +++ b/crates/sandlock-core/src/transparent_proxy/disabled.rs @@ -43,7 +43,7 @@ pub(crate) async fn spawn_transparent_proxy( _: Arc>, _: Option<&str>, _: Option<&str>, - _: Option>, + _: Option, ) -> io::Result { Err(disabled()) } diff --git a/crates/sandlock-core/src/transparent_proxy/mod.rs b/crates/sandlock-core/src/transparent_proxy/mod.rs index 9796e905..8eaec478 100644 --- a/crates/sandlock-core/src/transparent_proxy/mod.rs +++ b/crates/sandlock-core/src/transparent_proxy/mod.rs @@ -52,7 +52,7 @@ pub(crate) async fn spawn_transparent_proxy( inject: Arc>, ca_cert_pem: Option<&str>, ca_key_pem: Option<&str>, - log_fn: Option>, + observer: Option, ) -> std::io::Result { let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())); let forwarder = Forwarder::new()?; @@ -62,7 +62,7 @@ pub(crate) async fn spawn_transparent_proxy( inject, Arc::clone(&orig_dest), forwarder, - log_fn, + observer, ); let signer = match (ca_cert_pem, ca_key_pem) { diff --git a/crates/sandlock-core/src/transparent_proxy/service.rs b/crates/sandlock-core/src/transparent_proxy/service.rs index c86c1679..69ec92ea 100644 --- a/crates/sandlock-core/src/transparent_proxy/service.rs +++ b/crates/sandlock-core/src/transparent_proxy/service.rs @@ -16,7 +16,10 @@ use tokio::sync::Mutex; use super::upstream::{box_incoming, Forwarder}; use crate::credential::InjectRule; -use crate::http::{http_acl_check, HttpRule}; +use crate::http::{ + http_acl_check, HttpBodySink, HttpObserver, HttpRequestEvent, HttpResponseEvent, HttpRule, + HttpSinks, +}; type BoxError = Box; pub(crate) type OrigDestMap = Arc>>; @@ -41,9 +44,48 @@ pub(crate) struct AclService { /// so a library/API caller gets the warning once per run instead of per /// request. See [`first_cleartext_warn`]. cleartext_warned: Arc, - /// Optional observation callback for learn mode. Called with (method, host, path) - /// for every request after the host is validated, before the ACL check. - pub(crate) log_fn: Option>, + /// Called once per request, after the ACL has decided it. + pub(crate) observer: Option, +} + +/// Copies each body chunk to a sink on its way to the upstream. Driven by the +/// forward, so nothing is accumulated and an unforwarded body is unread. +struct TeeBody { + inner: B, + sink: HttpBodySink, +} + +impl hyper::body::Body for TeeBody +where + B: hyper::body::Body + Unpin, +{ + type Data = Bytes; + type Error = B::Error; + + fn poll_frame( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + let this = self.get_mut(); + match std::pin::Pin::new(&mut this.inner).poll_frame(cx) { + std::task::Poll::Ready(Some(Ok(frame))) => { + if let Some(data) = frame.data_ref() { + (this.sink)(data); + } + std::task::Poll::Ready(Some(Ok(frame))) + } + other => other, + } + } + + /// Forwarded, or the upstream request loses the length and switches to chunked. + fn size_hint(&self) -> hyper::body::SizeHint { + self.inner.size_hint() + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } } /// Whether this cleartext injection should emit the one-per-run warning: true the @@ -103,7 +145,7 @@ impl AclService { inject: Arc>, orig_dest: OrigDestMap, forwarder: Forwarder, - log_fn: Option>, + observer: Option, ) -> Self { Self { allow: Arc::new(allow), @@ -113,7 +155,7 @@ impl AclService { forwarder, dns_cache: Arc::new(Mutex::new(HashMap::new())), cleartext_warned: Arc::new(AtomicBool::new(false)), - log_fn, + observer, } } @@ -200,31 +242,50 @@ impl AclService { m.remove(&client_addr); } - if let Some(ref f) = self.log_fn { - f(&method, &host, &path); - } + let allowed = http_acl_check(&self.allow, &self.deny, &method, &host, &path); + + let sinks = match self.observer { + Some(ref f) => f(&HttpRequestEvent { + method: &method, + host: &host, + path: &path, + headers: req.headers(), + allowed, + }), + None => HttpSinks::default(), + }; - if !http_acl_check(&self.allow, &self.deny, &method, &host, &path) { + if !allowed { return text_response(StatusCode::FORBIDDEN, "Blocked by sandlock HTTP ACL policy"); } + let HttpSinks { + request: req_sink, + response: resp_hook, + } = sinks; + + let (mut parts, body) = req.into_parts(); + let body = match req_sink { + Some(sink) => TeeBody { inner: body, sink } + .map_err(|e| Box::new(e) as BoxError) + .boxed(), + None => box_incoming(body), + }; + // Rebuild an absolute-URI request for the upstream client from the SAME // validated authority — never the raw Host header — so the forward host // equals the host the ACL/credential match ran against. - let pq = req - .uri() + let pq = parts + .uri .path_and_query() .map(|p| p.as_str()) .unwrap_or("/") .to_string(); - let uri: hyper::Uri = match format!("{scheme}://{authority}{pq}").parse() { + parts.uri = match format!("{scheme}://{authority}{pq}").parse() { Ok(u) => u, Err(_) => return text_response(StatusCode::BAD_GATEWAY, "bad upstream URI"), }; - let (mut parts, body) = req.into_parts(); - parts.uri = uri; - // ACL passed: attach a credential if a rule matches. First match wins. // The secret is rendered into the outbound request only here — never on // the deny path above — and only its name is recorded, never the value. @@ -286,12 +347,26 @@ impl AclService { } } - let out_req = Request::from_parts(parts, box_incoming(body)); + let out_req = Request::from_parts(parts, body); - match self.forwarder.forward(out_req).await { + let resp = match self.forwarder.forward(out_req).await { Ok(resp) => resp, - Err(_) => text_response(StatusCode::BAD_GATEWAY, "upstream error"), - } + Err(_) => return text_response(StatusCode::BAD_GATEWAY, "upstream error"), + }; + + let Some(hook) = resp_hook else { + return resp; + }; + let (parts, body) = resp.into_parts(); + let sink = hook(&HttpResponseEvent { + status: parts.status.as_u16(), + headers: &parts.headers, + }); + let body = match sink { + Some(sink) => TeeBody { inner: body, sink }.boxed(), + None => body, + }; + Response::from_parts(parts, body) } } diff --git a/crates/sandlock-core/tests/integration/test_http_acl.rs b/crates/sandlock-core/tests/integration/test_http_acl.rs index 6d22585f..c532bf06 100644 --- a/crates/sandlock-core/tests/integration/test_http_acl.rs +++ b/crates/sandlock-core/tests/integration/test_http_acl.rs @@ -801,6 +801,363 @@ async fn test_env_sourced_credential_stripped_from_child() { ); } +// ============================================================ +// Observation callback: http_observer +// ============================================================ + +fn post_bytes_script(url: &str, body: &str, out: &std::path::Path) -> String { + let body_literal = format!("b'{}'", body); + format!( + concat!( + "import urllib.request, urllib.error\n", + "try:\n", + " req = urllib.request.Request('{url}', method='POST', data={body_literal})\n", + " resp = urllib.request.urlopen(req)\n", + " open('{out}', 'w').write('OK:' + str(resp.status))\n", + "except urllib.error.HTTPError as e:\n", + " open('{out}', 'w').write('HTTP:' + str(e.code))\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + url = url, + body_literal = body_literal, + out = out.display(), + ) +} + +/// An observer returning `None` still sees the request line and headers. +#[tokio::test] +async fn test_http_observer_records_requests() { + let out = temp_file("observer-records"); + let (port, srv) = spawn_http_server(1); + + let seen: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen_cb = seen.clone(); + + let policy = base_policy() + .http_allow("GET 127.0.0.1/*") + .http_port(port) + .http_observer(std::sync::Arc::new(move |event| { + let ua = event.headers.contains_key("user-agent"); + seen_cb.lock().unwrap().push(format!( + "{} {}{} allowed={} ua={}", + event.method, event.host, event.path, event.allowed, ua + )); + sandlock_core::http::HttpSinks::default() + })) + .build() + .unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); + let result = policy + .clone() + .run_interactive(&["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); + + assert_eq!( + seen.lock().unwrap().as_slice(), + ["GET 127.0.0.1/get allowed=true ua=true"] + ); +} + +/// Records the body of the one request it receives, so a test can tell the +/// sink's view and the upstream's view apart. +fn spawn_body_capturing_http_server() -> ( + u16, + thread::JoinHandle<()>, + std::sync::Arc>>>, +) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let cap = captured.clone(); + let handle = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if line.to_lowercase().starts_with("content-length:") { + content_length = line + .split(':') + .nth(1) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + } + if line == "\r\n" || line == "\n" { + break; + } + } + let mut body = vec![0u8; content_length]; + if content_length > 0 { + let _ = reader.read_exact(&mut body); + } + *cap.lock().unwrap() = Some(body); + let resp = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + (port, handle, captured) +} + +/// The sink receives the body, and the upstream receives it unmodified. +#[tokio::test] +async fn test_http_observer_sink_receives_body() { + let out = temp_file("observer-sink"); + let (port, srv, upstream_got) = spawn_body_capturing_http_server(); + + let teed: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let teed_cb = teed.clone(); + + let policy = base_policy() + .http_allow("POST 127.0.0.1/*") + .http_port(port) + .http_observer(std::sync::Arc::new(move |event| { + assert_eq!(event.method, "POST"); + assert_eq!(event.host, "127.0.0.1"); + assert_eq!(event.path, "/upload"); + assert!(event.allowed); + let acc = teed_cb.clone(); + sandlock_core::http::HttpSinks { + request: Some(Box::new(move |chunk: &[u8]| { + acc.lock().unwrap().extend_from_slice(chunk); + })), + response: None, + } + })) + .build() + .unwrap(); + + let script = post_bytes_script( + &format!("http://127.0.0.1:{}/upload", port), + "hello-observer", + &out, + ); + let result = policy + .clone() + .run_interactive(&["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); + + assert_eq!( + teed.lock().unwrap().as_slice(), + b"hello-observer", + "the sink must see the body the child sent" + ); + assert_eq!( + upstream_got.lock().unwrap().as_deref(), + Some(b"hello-observer".as_slice()), + "upstream must still receive the unmodified body" + ); +} + +/// A blocked request is reported with `allowed` false, and its body unread. +#[tokio::test] +async fn test_http_observer_reports_denied_request() { + let out = temp_file("observer-denied"); + let (port, _srv, _got) = spawn_body_capturing_http_server(); + + let verdicts: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let teed: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let verdicts_cb = verdicts.clone(); + let teed_cb = teed.clone(); + + let policy = base_policy() + .http_allow("POST 127.0.0.1/allowed") + .http_port(port) + .http_observer(std::sync::Arc::new(move |event| { + verdicts_cb.lock().unwrap().push(event.allowed); + let acc = teed_cb.clone(); + sandlock_core::http::HttpSinks { + request: Some(Box::new(move |chunk: &[u8]| { + acc.lock().unwrap().extend_from_slice(chunk); + })), + response: None, + } + })) + .build() + .unwrap(); + + let script = post_bytes_script( + &format!("http://127.0.0.1:{}/blocked", port), + "blocked-prompt", + &out, + ); + let result = policy + .clone() + .run_interactive(&["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!( + content.starts_with("HTTP:403"), + "expected 403, got: {}", + content + ); + let _ = std::fs::remove_file(&out); + + assert_eq!( + verdicts.lock().unwrap().as_slice(), + [false], + "the denied request must be reported once, with allowed=false" + ); + assert!( + teed.lock().unwrap().is_empty(), + "a denied request is never forwarded, so its body is never read" + ); +} + +/// A large body reaches the sink as several frames, and upstream whole. +#[tokio::test] +async fn test_http_observer_sink_receives_streamed_chunks() { + let out = temp_file("observer-chunks"); + let (port, srv, upstream_got) = spawn_body_capturing_http_server(); + + let chunks: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let chunks_cb = chunks.clone(); + + let policy = base_policy() + .http_allow("POST 127.0.0.1/*") + .http_port(port) + .http_observer(std::sync::Arc::new(move |_event| { + let acc = chunks_cb.clone(); + sandlock_core::http::HttpSinks { + request: Some(Box::new(move |chunk: &[u8]| { + acc.lock().unwrap().push(chunk.len()); + })), + response: None, + } + })) + .build() + .unwrap(); + + let size = 1_000_000usize; + let script = format!( + concat!( + "import urllib.request, urllib.error\n", + "body = b'x' * {size}\n", + "try:\n", + " req = urllib.request.Request('{url}', method='POST', data=body)\n", + " resp = urllib.request.urlopen(req)\n", + " open('{out}', 'w').write('OK:' + str(resp.status))\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + size = size, + url = format!("http://127.0.0.1:{}/upload", port), + out = out.display(), + ); + let result = policy + .clone() + .run_interactive(&["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "got: {}", content); + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); + + let seen = chunks.lock().unwrap(); + let total: usize = seen.iter().sum(); + assert_eq!(total, size, "sink must see every byte"); + assert!( + seen.len() > 1, + "expected several frames, got {}", + seen.len() + ); + assert_eq!( + upstream_got.lock().unwrap().as_ref().map(|b| b.len()), + Some(size), + "upstream must receive the whole body" + ); +} + +/// The response hook sees the upstream status, and its sink receives the +/// response body. +#[tokio::test] +async fn test_http_observer_response_hook_tees_body() { + let out = temp_file("observer-response"); + let (port, srv, _upstream_got) = spawn_body_capturing_http_server(); + + let status: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let resp_body: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let status_cb = status.clone(); + let resp_body_cb = resp_body.clone(); + + let policy = base_policy() + .http_allow("POST 127.0.0.1/*") + .http_port(port) + .http_observer(std::sync::Arc::new(move |_event| { + let seen_status = status_cb.clone(); + let acc = resp_body_cb.clone(); + sandlock_core::http::HttpSinks { + request: None, + response: Some(Box::new(move |resp| { + *seen_status.lock().unwrap() = Some(resp.status); + Some(Box::new(move |chunk: &[u8]| { + acc.lock().unwrap().extend_from_slice(chunk); + })) + })), + } + })) + .build() + .unwrap(); + + let script = post_bytes_script( + &format!("http://127.0.0.1:{}/upload", port), + "hello-response", + &out, + ); + let result = policy + .clone() + .run_interactive(&["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); + + assert_eq!( + *status.lock().unwrap(), + Some(200), + "the hook must see the upstream status" + ); + assert_eq!( + resp_body.lock().unwrap().as_slice(), + b"ok", + "the sink must see the response body" + ); +} + // NOTE (RFC #66 follow-up): injection over the HTTPS/MITM path is not covered // here — this suite has no TLS upstream or ephemeral-CA harness. The rendering // code is scheme-agnostic (service.rs runs the same `apply` after the ACL on the diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index e91e47db..d9fef2a7 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -132,8 +132,8 @@ Top-level configuration for the supervisor and COW workspace. | ------------ | ----------- | ------------- | ------- | ---------------------------------------------------------------------------------------- | | `http_ca` | `http_ca` | `str \| None` | `None` | PEM CA certificate path for HTTPS MITM. When set, port `443` is added to `http_ports`. | | `http_key` | `http_key` | `str \| None` | `None` | PEM CA private key path. Required whenever `http_ca` is set. | -| `http_inject_ca` | `http_inject_ca` | `list[str]` | `[]` | Trust bundle paths to splice the active MITM CA's public cert into at open time. Without `http_ca`, generates an ephemeral CA (private key in memory only, never on disk) and intercepts port `443`. Requires at least one `http_allow` / `http_deny` rule. | -| `http_ca_out` | `http_ca_out` | `str \| None` | `None` | Writes the active CA's public certificate (PEM) to this path; never the private key. Requires at least one `http_allow` / `http_deny` rule. | +| `http_inject_ca` | `http_inject_ca` | `list[str]` | `[]` | Trust bundle paths to splice the active MITM CA's public cert into at open time. Without `http_ca`, generates an ephemeral CA (private key in memory only, never on disk) and intercepts port `443`. Requires at least one `http_allow` / `http_deny` rule, or an observation callback. | +| `http_ca_out` | `http_ca_out` | `str \| None` | `None` | Writes the active CA's public certificate (PEM) to this path; never the private key. Requires at least one `http_allow` / `http_deny` rule, or an observation callback. | | `fs_storage` | `fs_storage`| `str \| None` | `None` | Separate storage directory for the seccomp COW upper layer / deltas. | | `workdir` | `workdir` | `str \| None` | `None` | COW root directory. Controls which directory COW tracks; does **not** set the child's working directory. |