feat: enforce owner allowlisted bwrap HTTPS egress
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
parent
d69827aaa2
commit
d477c3b5d9
11 changed files with 563 additions and 32 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
130
src/sandboxer/extensions/egress.py
Normal file
130
src/sandboxer/extensions/egress.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue