Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e5e5e85
feat(net): add secret substitution via TLS MITM proxy (#412)
DorianZheng Mar 29, 2026
671ada2
fix(python): skip secret tests when SDK lacks Secret class
DorianZheng Mar 29, 2026
75122b4
fix(net): fix MITM upstream transport and make /etc/hosts writable
DorianZheng Mar 29, 2026
6ad208a
fix(guest): inject MITM CA cert into container rootfs
DorianZheng Mar 29, 2026
b0cbe74
refactor(net): clean up MITM code after review
DorianZheng Mar 29, 2026
b67303d
revert(examples): remove secret test artifacts from interactive examples
DorianZheng Mar 29, 2026
6d6d800
refactor(net): move MITM CA generation from Go to Rust
DorianZheng Mar 29, 2026
5fe8c8c
refactor(net): move CA to crate::net::ca, pass via NetworkBackendConfig
DorianZheng Mar 30, 2026
b382693
fix(security): address code review findings for MITM PR
DorianZheng Mar 30, 2026
0e5111d
fix(security): second review round — pipe transport, revert /etc/hosts
DorianZheng Mar 30, 2026
cb4d571
refactor(proto): pass CA cert via gRPC CACert instead of env var
DorianZheng Mar 30, 2026
2da049e
refactor(guest): replace env var CA injection with CaInstaller
DorianZheng Mar 30, 2026
ddab06f
refactor: extract named helpers from inline logic blocks
DorianZheng Mar 30, 2026
06049af
fix: remove NewBoxCA() from production, fix stale Go library detection
DorianZheng Mar 30, 2026
d35472b
fix: address third review round (10 issues)
DorianZheng Mar 30, 2026
78451c9
chore: remove stale CA trust comment from guest main.rs
DorianZheng Mar 30, 2026
772538c
fix: address review round 4 (8 issues)
DorianZheng Mar 30, 2026
277aee3
fix: address review round 5 (10 issues)
DorianZheng Mar 30, 2026
90318a4
feat: persist MITM CA to box dir for restart support
DorianZheng Mar 30, 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
24 changes: 24 additions & 0 deletions Cargo.lock

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

8 changes: 8 additions & 0 deletions boxlite-shared/proto/boxlite/v1/service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ message ContainerInitRequest {
RootfsInit rootfs = 3;
// Bind mounts from guest VM paths into container namespace
repeated BindMount mounts = 4;
// Additional CA certificates to install in the container trust store.
// Used for MITM secret substitution — the container trusts the proxy CA.
repeated CACert ca_certs = 5;
}

// A CA certificate to add to the container's trust store.
message CACert {
string pem = 1; // PEM-encoded X.509 certificate
}

// Bind mount from guest volume to container path
Expand Down
2 changes: 2 additions & 0 deletions boxlite/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ hex = "0.4.3"
signal-hook = "0.3"
reflink-copy = "0.1"
nanoid = "0.4"
rcgen = "0.13"
time = "0.3"

# REST backend (optional)
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], optional = true, default-features = false }
Expand Down
22 changes: 17 additions & 5 deletions boxlite/deps/libgvproxy-sys/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,23 @@ fn build_gvproxy(source_dir: &Path, output_path: &Path) {
}

fn main() {
// Rebuild if Go sources change
println!("cargo:rerun-if-changed=gvproxy-bridge/main.go");
println!("cargo:rerun-if-changed=gvproxy-bridge/stats.go");
println!("cargo:rerun-if-changed=gvproxy-bridge/dns_filter.go");
println!("cargo:rerun-if-changed=gvproxy-bridge/go.mod");
// Rebuild when any Go source file changes.
// cargo:rerun-if-changed on a directory only detects file additions/removals,
// not content changes. Walk the directory and watch each .go file individually.
let bridge_dir = Path::new("gvproxy-bridge");
if bridge_dir.is_dir() {
for entry in fs::read_dir(bridge_dir).expect("Failed to read gvproxy-bridge directory") {
let entry = entry.expect("Failed to read directory entry");
let path = entry.path();
if path
.extension()
.is_some_and(|ext| ext == "go" || ext == "mod" || ext == "sum")
{
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
println!("cargo:rerun-if-changed=gvproxy-bridge"); // also watch for new files
println!("cargo:rerun-if-env-changed=BOXLITE_DEPS_STUB");

// Auto-detect crates.io download: Cargo injects .cargo_vcs_info.json into
Expand Down
4 changes: 3 additions & 1 deletion boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_network.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func OverrideTCPHandler(
config *types.Configuration,
ec2MetadataAccess bool,
filter *TCPFilter,
ca *BoxCA,
secretMatcher *SecretHostMatcher,
) error {
// Access private stack field via reflect
v := reflect.ValueOf(vn).Elem()
Expand All @@ -51,7 +53,7 @@ func OverrideTCPHandler(

// Replace TCP handler with our filtered version
var natLock sync.Mutex
tcpFwd := TCPWithFilter(s, nat, &natLock, ec2MetadataAccess, filter)
tcpFwd := TCPWithFilter(s, nat, &natLock, ec2MetadataAccess, filter, ca, secretMatcher)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpFwd.HandlePacket)

logrus.Info("allowNet TCP: handler overridden with SNI-inspecting forwarder")
Expand Down
63 changes: 50 additions & 13 deletions boxlite/deps/libgvproxy-sys/gvproxy-bridge/forked_tcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,31 @@ import (
// TCPWithFilter creates a TCP forwarder that checks the filter before allowing
// outbound connections. For port 443/80 with hostname rules, it inspects
// TLS SNI / HTTP Host headers to match against the allowlist.
// linkLocalSubnet is 169.254.0.0/16, parsed once at init (not per-packet).
var linkLocalSubnet tcpip.Subnet

func init() {
_, linkLocalNet, err := net.ParseCIDR("169.254.0.0/16")
if err != nil {
panic("failed to parse link-local CIDR: " + err.Error())
}
var subnetErr error
linkLocalSubnet, subnetErr = tcpip.NewSubnet(
tcpip.AddrFromSlice(linkLocalNet.IP),
tcpip.MaskFromBytes(linkLocalNet.Mask),
)
if subnetErr != nil {
panic("failed to create link-local subnet: " + subnetErr.Error())
}
}

func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address,
natLock *sync.Mutex, ec2MetadataAccess bool, filter *TCPFilter) *tcp.Forwarder {
natLock *sync.Mutex, ec2MetadataAccess bool, filter *TCPFilter,
ca *BoxCA, secretMatcher *SecretHostMatcher) *tcp.Forwarder {

return tcp.NewForwarder(s, 0, 10, func(r *tcp.ForwarderRequest) {
localAddress := r.ID().LocalAddress

// Block link-local (169.254.0.0/16) unless EC2 metadata access enabled
_, linkLocalNet, _ := net.ParseCIDR("169.254.0.0/16")
linkLocalSubnet, _ := tcpip.NewSubnet(
tcpip.AddrFromSlice(linkLocalNet.IP),
tcpip.MaskFromBytes(linkLocalNet.Mask),
)
if !ec2MetadataAccess && linkLocalSubnet.Contains(localAddress) {
r.Complete(true)
return
Expand All @@ -59,12 +72,24 @@ func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address,
destPort := r.ID().LocalPort
destAddr := fmt.Sprintf("%s:%d", localAddress, destPort)

// No filter: standard upstream flow
// Secrets-only mode (no allowlist filter): MITM secret hosts, allow everything else
if filter == nil {
if secretMatcher != nil && destPort == 443 {
inspectAndForward(r, destAddr, destPort, nil, ca, secretMatcher)
return
}
standardForward(r, destAddr)
return
}

// Port 443 with secrets: MUST inspect SNI even if IP matches allowlist,
// because we need to know the hostname to decide MITM vs passthrough.
// The IP match alone can't tell us if it's a secret host.
if secretMatcher != nil && destPort == 443 {
inspectAndForward(r, destAddr, destPort, filter, ca, secretMatcher)
return
}

// IP/CIDR match: standard upstream flow (allowed)
if filter.MatchesIP(destIP) {
standardForward(r, destAddr)
Expand All @@ -73,7 +98,7 @@ func TCPWithFilter(s *stack.Stack, nat map[tcpip.Address]tcpip.Address,

// Port 443/80 with hostname rules: inspect SNI/Host
if filter.HasHostnameRules() && (destPort == 443 || destPort == 80) {
inspectAndForward(r, destAddr, destPort, filter)
inspectAndForward(r, destAddr, destPort, filter, ca, secretMatcher)
return
}

Expand Down Expand Up @@ -119,7 +144,7 @@ func standardForward(r *tcp.ForwarderRequest, destAddr string) {
// inspectAndForward: Accept → Peek SNI/Host → check allowlist → Dial → relay.
// The flow is reversed from upstream because we need to read from the guest
// before deciding whether to connect to the upstream server.
func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16, filter *TCPFilter) {
func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16, filter *TCPFilter, ca *BoxCA, secretMatcher *SecretHostMatcher) {
// Step 1: Accept TCP from guest first (reversed from upstream)
var wq waiter.Queue
ep, tcpErr := r.CreateEndpoint(&wq)
Expand All @@ -143,8 +168,20 @@ func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16
hostname = peekHTTPHost(br)
}

// Step 3: Check allowlist
if hostname == "" || !filter.MatchesHostname(hostname) {
// Step 3: Check for MITM secret substitution (HTTPS only, takes priority over allowlist)
if destPort == 443 && secretMatcher != nil && hostname != "" && secretMatcher.Matches(hostname) {
secrets := secretMatcher.SecretsForHost(hostname)
logrus.WithFields(logrus.Fields{
"hostname": hostname,
"num_secrets": len(secrets),
}).Debug("MITM: intercepting for secret substitution")
bufferedGuest := &bufferedConn{Conn: guestConn, reader: br}
mitmAndForward(bufferedGuest, hostname, destAddr, ca, secrets)
return
}

// Step 4: Check allowlist (skip if no allowlist — secrets-only mode allows all traffic)
if filter != nil && (hostname == "" || !filter.MatchesHostname(hostname)) {
logrus.WithFields(logrus.Fields{
"dst": destAddr,
"hostname": hostname,
Expand All @@ -158,7 +195,7 @@ func inspectAndForward(r *tcp.ForwarderRequest, destAddr string, destPort uint16
"hostname": hostname,
}).Debug("allowNet TCP: allowed by hostname")

// Step 4: Dial upstream
// Step 5: Dial upstream
outbound, err := net.Dial("tcp", destAddr)
if err != nil {
logrus.WithField("error", err).Trace("allowNet TCP: upstream dial failed")
Expand Down
Loading
Loading