Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
334b547
ca_inject: add trust-bundle injection handler
congwang-mk Jun 2, 2026
885ad9a
sandbox: add --http-inject-ca and --http-ca-out plumbing and validation
congwang-mk Jun 2, 2026
778fd92
sandbox: wire ephemeral CA generation and trust injection
congwang-mk Jun 2, 2026
8824410
ffi: expose http_inject_ca and http_ca_out builder functions
congwang-mk Jun 2, 2026
0ed811e
profile: support http_inject_ca and http_ca_out in [config]
congwang-mk Jun 2, 2026
8926f7a
python: add http_inject_ca and http_ca_out to SDK and profile
congwang-mk Jun 2, 2026
0e3e2ea
test: end-to-end CA injection into declared trust bundle
congwang-mk Jun 3, 2026
a9ab78f
docs: document --http-inject-ca and --http-ca-out
congwang-mk Jun 3, 2026
63b6fad
cli: forward http_inject_ca and http_ca_out to the builder
congwang-mk Jun 3, 2026
ffb84dd
deps: add rustls/hyper/rcgen direct deps for transparent proxy
congwang-mk Jun 3, 2026
aac4dc9
transparent_proxy: per-SNI leaf cert signer
congwang-mk Jun 3, 2026
d348a27
transparent_proxy: upstream forwarder client
congwang-mk Jun 3, 2026
9708d86
transparent_proxy: ACL request service with host verification
congwang-mk Jun 3, 2026
02cc7a8
transparent_proxy: accept loop, TLS classify, MITM termination, serve
congwang-mk Jun 3, 2026
aec93f5
sandbox: use the transparent proxy for HTTP ACL
congwang-mk Jun 3, 2026
6d6d8b8
transparent_proxy: give CA and leaf distinct subject CNs so leaf is n…
congwang-mk Jun 3, 2026
33eb2a5
test: hermetic HTTPS MITM termination and ACL deny
congwang-mk Jun 3, 2026
7b72022
http_acl: drop hudsucker, keep CA helpers on direct rcgen
congwang-mk Jun 3, 2026
d495bfb
http_acl: remove unused DUMMY_CA static
congwang-mk Jun 3, 2026
cc52926
sandbox: error when --http-inject-ca path is missing from the sandbox…
congwang-mk Jun 3, 2026
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
792 changes: 71 additions & 721 deletions Cargo.lock

Large diffs are not rendered by default.

36 changes: 27 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,23 @@ sandlock run \
--http-deny "* */admin/*" \
-r /usr -r /lib -r /etc -- python3 agent.py

# HTTPS MITM with user-provided CA (enables ACL on port 443)
# Generate a CA, add the cert to the sandbox's trust store
# (e.g. /etc/ssl/certs/), then pass both files here.
# HTTPS MITM, zero-config: sandlock generates an ephemeral CA and splices it
# into the trust bundle(s) you name. No openssl, no manual install.
sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
-r /usr -r /lib -r /etc -- python3 agent.py

# Node and other runtimes with a compiled-in CA list: export the cert and
# wire the runtime's own env var.
sandlock run \
--http-allow "POST api.example.com/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
--http-ca-out /tmp/sandlock-ca.pem \
--env NODE_EXTRA_CA_CERTS=/tmp/sandlock-ca.pem \
-r /usr -r /lib -r /etc -- node agent.js

# HTTPS MITM with your own CA (still supported)
sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--http-ca ca.pem --http-key ca-key.pem \
Expand Down Expand Up @@ -627,12 +641,16 @@ matching ports through a transparent proxy. Each rule with a concrete
host auto-extends `--net-allow` with `host:80` (and `host:443` when
`--http-ca` is set) so the proxy's intercept ports are reachable;
wildcard hosts auto-add `:80` / `:443` (any IP). All auto-added
entries are TCP. HTTPS MITM is opt-in: pass `--http-ca <cert>` and
`--http-key <key>` for a CA *you generate* and trust inside the
sandbox (typically install the cert into the workload's
`/etc/ssl/certs/`). Without `--http-ca`, port 443 is not intercepted
— `--net-allow host:443` permits raw TLS to the host with no content
inspection.
entries are TCP. HTTPS MITM is enabled two ways: pass `--http-ca <cert>`
and `--http-key <key>` to bring your own CA, or pass `--http-inject-ca
<bundle>` to have sandlock generate an ephemeral CA (private key in
memory only) and splice its public cert into each named trust bundle at
open time, so the workload trusts the proxy with no manual install. For
runtimes with a compiled-in CA store such as Node, `--http-ca-out
<path>` writes the public cert so you can point the runtime's own env
var at it (e.g. `NODE_EXTRA_CA_CERTS`). Without any of these, port 443
is not intercepted: `--net-allow host:443` permits raw TLS to the host
with no content inspection.

**Bind.** `--net-bind <port>` is independent from `--net-allow` and
governs server-side `bind()`. Landlock enforces it (TCP only);
Expand Down
4 changes: 4 additions & 0 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ async fn run_command(args: RunArgs) -> Result<i32> {
// HTTP MITM material
if let Some(ref ca) = base.http_ca { b = b.http_ca(ca); }
if let Some(ref key) = base.http_key { b = b.http_key(key); }
for p in &base.http_inject_ca { b = b.http_inject_ca(p); }
if let Some(ref out) = base.http_ca_out { b = b.http_ca_out(out); }
// Filesystem extras
if let Some(ref path) = base.chroot { b = b.chroot(path); }
if let Some(ref path) = base.fs_storage { b = b.fs_storage(path); }
Expand Down Expand Up @@ -391,6 +393,8 @@ async fn run_command(args: RunArgs) -> Result<i32> {
for port in &pb.http_ports { builder = builder.http_port(*port); }
if let Some(ref ca) = pb.http_ca { builder = builder.http_ca(ca); }
if let Some(ref key) = pb.http_key { builder = builder.http_key(key); }
for p in &pb.http_inject_ca { builder = builder.http_inject_ca(p); }
if let Some(ref out) = pb.http_ca_out { builder = builder.http_ca_out(out); }
if pb.port_remap { builder = builder.port_remap(true); }
if pb.no_randomize_memory { builder = builder.no_randomize_memory(true); }
if pb.no_huge_pages { builder = builder.no_huge_pages(true); }
Expand Down
9 changes: 8 additions & 1 deletion crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ walkdir = "2"
toml = "0.8"
jiff = "0.2"
pathdiff = "0.2"
hudsucker = "0.22"
tokio-rustls = "0.25"
rustls = "0.22"
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
hyper = { version = "1", features = ["server", "client", "http1"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
hyper-rustls = "0.26"
tar = "0.4"
clap = { version = "4", features = ["derive"], optional = true }
bollard = "0.21"
Expand All @@ -38,3 +44,4 @@ cli = ["dep:clap"]
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
tempfile = "3"
rustls-pemfile = "2"
82 changes: 82 additions & 0 deletions crates/sandlock-core/src/ca_inject.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Trust-store injection: splice the active MITM CA into user-declared trust
// bundles at openat time. The child's open is intercepted before the kernel
// performs it; we read the child's real file via /proc/<pid>/root, append the
// CA PEM, and inject the combined bytes as a sealed memfd. Landlock is never
// consulted for the intercepted open (the syscall result is our memfd).

use std::os::unix::io::RawFd;
use std::path::{Path, PathBuf};

use crate::seccomp::notif::NotifAction;
use crate::sys::structs::SeccompNotif;

/// Append `ca_pem` to `original` bundle contents, ensuring a newline between
/// them so the concatenation is a valid multi-cert PEM file.
pub(crate) fn combine_bundle(original: &[u8], ca_pem: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(original.len() + ca_pem.len() + 1);
out.extend_from_slice(original);
if !original.is_empty() && !original.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(ca_pem);
out
}

/// True if `resolved` exactly matches one of the user-declared inject paths.
pub(crate) fn path_matches(resolved: &Path, inject_paths: &[PathBuf]) -> bool {
inject_paths.iter().any(|p| p == resolved)
}

/// Intercept an open-family syscall targeting a declared trust bundle and
/// return a memfd containing the original bundle plus the active CA.
///
/// Returns `None` (fall through to the kernel) when: the syscall is not an
/// open variant, the path is not a declared bundle, or the child's file
/// cannot be read host-side. Falling through is safe: it just lets the
/// normal open proceed, subject to the rest of the policy.
pub(crate) fn handle_ca_inject_open(
notif: &SeccompNotif,
inject_paths: &[PathBuf],
ca_pem: &[u8],
notif_fd: RawFd,
) -> Option<NotifAction> {
let resolved = crate::procfs::resolve_open_target(notif, notif_fd)?;
if !path_matches(&resolved, inject_paths) {
return None;
}
// Read the file as the child sees it (chroot/COW aware) via /proc/<pid>/root.
let child_view = format!("/proc/{}/root{}", notif.pid, resolved.to_str()?);
let original = std::fs::read(&child_view).ok()?;
let combined = combine_bundle(&original, ca_pem);
Some(NotifAction::inject_bytes(&combined))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn combine_inserts_newline_when_missing() {
let out = combine_bundle(b"AAA", b"BBB\n");
assert_eq!(out, b"AAA\nBBB\n");
}

#[test]
fn combine_no_extra_newline_when_present() {
let out = combine_bundle(b"AAA\n", b"BBB\n");
assert_eq!(out, b"AAA\nBBB\n");
}

#[test]
fn combine_empty_original() {
let out = combine_bundle(b"", b"BBB\n");
assert_eq!(out, b"BBB\n");
}

#[test]
fn path_matches_exact_only() {
let paths = vec![PathBuf::from("/etc/ssl/certs/ca-certificates.crt")];
assert!(path_matches(Path::new("/etc/ssl/certs/ca-certificates.crt"), &paths));
assert!(!path_matches(Path::new("/etc/ssl/certs/other.crt"), &paths));
}
}
Loading
Loading