feat: enforce owner allowlisted bwrap HTTPS egress
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-05 22:08:18 +02:00
parent d69827aaa2
commit d477c3b5d9
11 changed files with 563 additions and 32 deletions

View file

@ -75,6 +75,8 @@ class SandboxManager:
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
"provider": status.inputs.get("provider", ""),
"pid": status.inputs.get("pid", ""),
"egress_pid": status.inputs.get("egress_pid", ""),
"egress_dir": status.inputs.get("egress_dir", ""),
"workspace_dir": status.inputs.get("workspace_dir", ""),
}
@ -166,6 +168,8 @@ class SandboxManager:
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
status.inputs["provider"] = handle.get("provider", "")
status.inputs["pid"] = handle.get("pid", "")
status.inputs["egress_pid"] = handle.get("egress_pid", "")
status.inputs["egress_dir"] = handle.get("egress_dir", "")
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
reach = backend.wait_ready(handle)
reach = enrich_reachability(reach, profile, handle)

View file

@ -9,12 +9,14 @@ import shutil
import signal
import socket
import subprocess
import sys
import time
from contextlib import suppress
from pathlib import Path
from typing import Any
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.egress import destinations
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
from sandboxer.models import Profile
@ -36,11 +38,9 @@ class BwrapExtension(SandboxExtension):
cfg = self.config
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
self.control_socket_name: str = cfg.get(
"control_socket_name", ".sandboxer-owner.sock"
)
self.control_socket_name: str = cfg.get("control_socket_name", ".sandboxer-owner.sock")
self.ro_binds: list[str] = cfg.get(
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"]
)
def _bwrap_bin(self) -> str:
@ -49,7 +49,9 @@ class BwrapExtension(SandboxExtension):
def _existing_ro_binds(self) -> list[str]:
return [path for path in self.ro_binds if Path(path).exists()]
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
def _bwrap_argv(
self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None
) -> list[str]:
argv = [
self._bwrap_bin(),
"--unshare-user",
@ -73,6 +75,10 @@ class BwrapExtension(SandboxExtension):
runner = Path(__file__).with_name("bwrap_runner.py")
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
if egress_socket:
egress_module = Path(__file__).with_name("egress.py")
argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"]
argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"]
runtime = verified_runtime(self.config)
if runtime is not None:
workspace = Path(workspace_dir).resolve()
@ -90,6 +96,8 @@ class BwrapExtension(SandboxExtension):
]
if runtime is not None:
argv.append("--runtime")
if egress_socket:
argv.append("--egress")
return argv
@staticmethod
@ -109,11 +117,13 @@ class BwrapExtension(SandboxExtension):
raise RuntimeError("bwrap reported an invalid namespace child pid")
return child_pid
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
if profile.network.default != "deny" or profile.network.egress:
raise ValueError("bwrap currently supports only default-deny with empty egress")
def provision(self, profile: Profile, inputs: dict[str, str], host: str) -> dict[str, str]:
if profile.network.default != "deny":
raise ValueError("bwrap requires default-deny networking")
requested = destinations(profile.network.egress)
allowed = destinations(self.config.get("allowed_egress", []))
if not requested.issubset(allowed):
raise ValueError("profile egress exceeds owner allowlist")
if profile.setup.secret_refs:
raise ValueError("bwrap has no setup credential delivery contract")
runtime = verified_runtime(self.config)
@ -133,22 +143,60 @@ class BwrapExtension(SandboxExtension):
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
Path(workspace_dir).chmod(0o700)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
proc = subprocess.Popen(
argv,
proxy = None
proxy_dir = None
egress_socket = None
if requested:
import tempfile
proxy_dir = tempfile.mkdtemp(prefix="sandboxer-egress-")
egress_socket = str(Path(proxy_dir) / "proxy.sock")
proxy = subprocess.Popen(
[
sys.executable,
str(Path(__file__).with_name("egress.py")),
egress_socket,
*sorted(requested),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
pass_fds=(info_write_fd,),
)
finally:
os.close(info_write_fd)
deadline = time.monotonic() + 5
while not Path(egress_socket).is_socket():
if proxy.poll() is not None or time.monotonic() >= deadline:
proxy.kill()
proxy.wait()
shutil.rmtree(proxy_dir)
raise RuntimeError("egress proxy failed to start")
time.sleep(0.02)
try:
child_pid = self._read_child_pid(proc, info_read_fd)
finally:
os.close(info_read_fd)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(
workspace_dir, info_fd=info_write_fd, egress_socket=egress_socket
)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
pass_fds=(info_write_fd,),
)
finally:
os.close(info_write_fd)
try:
child_pid = self._read_child_pid(proc, info_read_fd)
finally:
os.close(info_read_fd)
except BaseException:
if proxy:
proxy.kill()
proxy.wait()
if proxy_dir:
shutil.rmtree(proxy_dir, ignore_errors=True)
raise
return {
"sandbox_id": sandbox_id,
@ -156,9 +204,18 @@ class BwrapExtension(SandboxExtension):
"pid": str(child_pid),
"supervisor_pid": str(proc.pid),
"workspace_dir": workspace_dir,
**({"egress_pid": str(proxy.pid), "egress_dir": proxy_dir} if proxy else {}),
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
try:
return self._wait_ready(handle)
except Exception:
if handle.get("egress_pid"):
self.teardown(handle)
raise
def _wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
pid = int(handle["pid"])
if not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} is not running")
@ -252,6 +309,13 @@ class BwrapExtension(SandboxExtension):
os.kill(pid, signal.SIGKILL)
killed = True
if handle.get("egress_pid"):
with suppress(ProcessLookupError):
os.killpg(int(handle["egress_pid"]), signal.SIGKILL)
with suppress(ChildProcessError):
os.waitpid(int(handle["egress_pid"]), 0)
if handle.get("egress_dir"):
shutil.rmtree(handle["egress_dir"], ignore_errors=True)
workspace_dir = handle.get("workspace_dir", "")
removed = False
if workspace_dir and Path(workspace_dir).exists():

View file

@ -24,7 +24,7 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
def _run(
payload: dict, workspace: Path, *, runtime_enabled: bool = False
payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None
) -> dict[str, object]:
command = payload["command"]
timeout_seconds = int(payload["timeout_seconds"])
@ -47,6 +47,9 @@ def _run(
}
if runtime_enabled:
child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"]
if proxy_port is not None:
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
child_env["https_proxy"] = child_env["HTTPS_PROXY"]
timed_out = False
process = subprocess.Popen(
command,
@ -59,9 +62,7 @@ def _run(
)
try:
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
stdout_raw, stderr_raw = process.communicate(
input=stdin_bytes, timeout=timeout_seconds
)
stdout_raw, stderr_raw = process.communicate(input=stdin_bytes, timeout=timeout_seconds)
exit_code = process.returncode
except subprocess.TimeoutExpired:
timed_out = True
@ -83,9 +84,14 @@ def _run(
def main() -> int:
workspace = Path(sys.argv[1]).resolve(strict=True)
socket_path = Path(sys.argv[2])
runtime_enabled = sys.argv[3:] == ["--runtime"]
if sys.argv[3:] and not runtime_enabled:
runtime_enabled = "--runtime" in sys.argv[3:]
if any(flag not in {"--runtime", "--egress"} for flag in sys.argv[3:]):
raise ValueError("invalid owner runtime mode")
proxy_port = None
if "--egress" in sys.argv[3:]:
from egress import bridge
proxy_port = bridge("/run/sandboxer/egress.sock")
state = Path("/run/sandboxer/state")
state.mkdir(mode=0o700)
for name in ("home", "config", "cache", "data", "tmp"):
@ -113,7 +119,10 @@ def main() -> int:
if not chunks:
raise ValueError("empty or oversized execution request")
response = _run(
json.loads(b"".join(chunks)), workspace, runtime_enabled=runtime_enabled
json.loads(b"".join(chunks)),
workspace,
runtime_enabled=runtime_enabled,
proxy_port=proxy_port,
)
except Exception as exc:
response = {"boundary_error": str(exc)}

View file

@ -0,0 +1,130 @@
"""Bounded HTTPS CONNECT egress; host-side destination enforcement."""
from __future__ import annotations
import ipaddress
import select
import socket
import threading
import time
from contextlib import suppress
from pathlib import Path
def destinations(entries: list[str]) -> frozenset[str]:
"""Only exact lowercase DNS names with an explicit TLS port."""
import re
if any(
not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}:443", x)
for x in entries
):
raise ValueError("egress requires exact lowercase DNS names with :443")
return frozenset(entries)
def connect_public(host: str) -> socket.socket:
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
raise ValueError("non-public destination")
# Connect to the already checked numeric address; never resolve a second time.
for family, kind, proto, _, address in addresses:
connection = socket.socket(family, kind, proto)
connection.settimeout(10)
try:
connection.connect(address)
return connection
except OSError:
connection.close()
raise OSError("destination unavailable")
def relay(left: socket.socket, right: socket.socket) -> None:
deadline = time.monotonic() + 900
for connection in (left, right):
connection.settimeout(10)
while time.monotonic() < deadline:
ready, _, _ = select.select([left, right], [], [], 30)
if not ready:
return
for source in ready:
chunk = source.recv(65536)
if not chunk:
return
(right if source is left else left).sendall(chunk)
def tunnel(client: socket.socket, allowed: frozenset[str]) -> None:
client.settimeout(10)
header = bytearray()
# Do not consume any TLS bytes following CONNECT headers.
while not header.endswith(b"\r\n\r\n"):
byte = client.recv(1)
if not byte or len(header) >= 8192:
raise ValueError("invalid CONNECT header")
header.extend(byte)
lines = header.decode("ascii").split("\r\n")
request = lines[0].split(" ")
if len(request) != 3 or request[0] != "CONNECT" or request[2] != "HTTP/1.1":
raise ValueError("CONNECT required")
target = request[1]
if target not in allowed:
raise ValueError("destination denied")
# No request body or transfer framing on CONNECT.
for line in lines[1:-2]:
name, separator, value = line.partition(":")
if not separator or name.lower() not in {"host", "proxy-connection", "user-agent"}:
raise ValueError("unsupported CONNECT header")
if name.lower() == "host" and value.strip() != target:
raise ValueError("inconsistent host")
with connect_public(target[:-4]) as upstream:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
relay(client, upstream)
def serve(listener: socket.socket, handler) -> None:
slots = threading.BoundedSemaphore(16)
def worker(client):
try:
with client, suppress(OSError, ValueError, UnicodeError):
handler(client)
finally:
slots.release()
listener.listen(16)
while True:
client, _ = listener.accept()
if not slots.acquire(blocking=False):
client.close()
continue
threading.Thread(target=worker, args=(client,), daemon=True).start()
def bridge(unix_path: str) -> int:
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
def forward(client):
with socket.socket(socket.AF_UNIX) as upstream:
upstream.connect(unix_path)
relay(client, upstream)
threading.Thread(target=serve, args=(listener, forward), daemon=True).start()
return port
def main() -> None:
import sys
path = Path(sys.argv[1])
allowed = destinations(sys.argv[2:])
with socket.socket(socket.AF_UNIX) as listener:
listener.bind(str(path))
path.chmod(0o600)
serve(listener, lambda client: tunnel(client, allowed))
if __name__ == "__main__":
main()