Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn(&str, &str, &str) + Send + Sync> =
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);
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions crates/sandlock-core/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn FnMut(&[u8]) + Send + Sync>;

/// 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<dyn FnOnce(&HttpResponseEvent) -> Option<HttpBodySink> + 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<HttpBodySink>,
/// `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<HttpResponseHook>,
}

/// Observes every request reaching the proxy.
pub type HttpObserver = std::sync::Arc<dyn Fn(&HttpRequestEvent) -> HttpSinks + Send + Sync>;

/// An HTTP access control rule.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HttpRule {
Expand Down
13 changes: 7 additions & 6 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// 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<std::sync::Arc<dyn Fn(&str, &str, &str) + Send + Sync>>,
pub(crate) http_observer: Option<crate::http::HttpObserver>,

// Resource limits
pub max_memory: Option<ByteSize>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2440,7 +2440,8 @@ impl Sandbox {
);

let mut ca_inject_pem: Option<std::sync::Arc<Vec<u8>>> = 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(
Expand Down Expand Up @@ -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)?;
Expand Down
36 changes: 20 additions & 16 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,10 @@ pub struct SandboxBuilder {
#[cfg_attr(feature = "cli", arg(long = "http-ca-out", value_name = "PATH"))]
pub http_ca_out: Option<PathBuf>,

/// 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<std::sync::Arc<dyn Fn(&str, &str, &str) + Send + Sync>>,
pub http_observer: Option<crate::http::HttpObserver>,

// max_memory uses a string in the CLI (e.g. "512M"); not directly clap-friendly as ByteSize.
#[cfg_attr(feature = "cli", clap(skip))]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -607,11 +607,12 @@ impl SandboxBuilder {
self
}

pub fn http_log_fn(
mut self,
f: std::sync::Arc<dyn Fn(&str, &str, &str) + Send + Sync>,
) -> 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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/sandlock-core/src/transparent_proxy/disabled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(crate) async fn spawn_transparent_proxy(
_: Arc<Vec<crate::credential::InjectRule>>,
_: Option<&str>,
_: Option<&str>,
_: Option<Arc<dyn Fn(&str, &str, &str) + Send + Sync>>,
_: Option<crate::http::HttpObserver>,
) -> io::Result<HttpAclProxyHandle> {
Err(disabled())
}
4 changes: 2 additions & 2 deletions crates/sandlock-core/src/transparent_proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub(crate) async fn spawn_transparent_proxy(
inject: Arc<Vec<crate::credential::InjectRule>>,
ca_cert_pem: Option<&str>,
ca_key_pem: Option<&str>,
log_fn: Option<Arc<dyn Fn(&str, &str, &str) + Send + Sync>>,
observer: Option<crate::http::HttpObserver>,
) -> std::io::Result<HttpAclProxyHandle> {
let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
let forwarder = Forwarder::new()?;
Expand All @@ -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) {
Expand Down
Loading
Loading