Skip to content

Commit 5fbb36a

Browse files
committed
feat: add public HTTPS sandbox egress mode
1 parent 9f5a3ba commit 5fbb36a

6 files changed

Lines changed: 99 additions & 12 deletions

File tree

.env.example

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ API_KEY=your-secure-api-key-here-change-this-in-production
1919
# 3. (none, when AUTH_ENABLED=false)
2020

2121
# ── Sandbox network access (skill installs) ───────────────────
22-
# When ENABLE_SANDBOX_NETWORK=true, sandboxes can reach the internet but only
23-
# through an inline allowlist proxy that permits PyPI, npm, Go modules, and
24-
# crates.io. Required for skills that pip/npm/go install dependencies at
25-
# runtime. Off by default (sandboxes are isolated).
22+
# When ENABLE_SANDBOX_NETWORK=true, sandboxes can reach the internet through
23+
# an inline proxy. Default allowlist mode permits PyPI, npm, Go modules, and
24+
# crates.io. Use public_https mode to permit arbitrary public HTTPS endpoints
25+
# while still blocking private/link-local/internal addresses.
2626
#
2727
# ENABLE_SANDBOX_NETWORK=false
28+
# SANDBOX_EGRESS_MODE=allowlist # allowlist or public_https
2829
# SANDBOX_EGRESS_PORT=18443 # local-only, sandbox -> proxy
2930
# SANDBOX_EGRESS_ALLOWLIST= # comma-separated extra hosts
3031
# SKILL_DEPS_PATH=/opt/skill-deps # backing volume mount

docs/CONFIGURATION.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,13 +270,16 @@ Inactive states are automatically archived to S3 for long-term storage.
270270
| `ENABLE_NETWORK_ISOLATION` | `true` | Enable network isolation for sandboxes |
271271
| `ENABLE_FILESYSTEM_ISOLATION` | `true` | Enable filesystem isolation |
272272

273-
### Sandbox Network Access (Skill Installs)
273+
### Sandbox Network Access
274274

275-
Off by default — sandboxes have no network access. When enabled, an inline allowlist HTTPS proxy on `127.0.0.1` lets sandboxes reach **only** package registries (PyPI, npm, Go modules, crates.io). Required for "skills" that `pip install` / `npm install` / `go get` / `cargo install` dependencies at runtime.
275+
Off by default — sandboxes have no network access. When enabled, an inline HTTPS proxy on `127.0.0.1` lets sandboxes reach external hosts without giving sandbox processes direct network access. The default `allowlist` mode permits only package registries (PyPI, npm, Go modules, crates.io) plus any hostnames in `SANDBOX_EGRESS_ALLOWLIST`. This is required for "skills" that `pip install` / `npm install` / `go get` / `cargo install` dependencies at runtime.
276+
277+
For agent workflows that need to retrieve data from public APIs, set `SANDBOX_EGRESS_MODE=public_https`. Public HTTPS mode permits arbitrary public HTTPS hostnames on port `443`, while still rejecting private, loopback, link-local, reserved, multicast, and unspecified IP addresses. The iptables egress firewall remains active, so sandbox processes still cannot bypass the proxy with direct sockets.
276278

277279
| Variable | Default | Description |
278280
| -------------------------- | --------------------- | --------------------------------------------------------------------------------- |
279281
| `ENABLE_SANDBOX_NETWORK` | `false` | Allow sandboxes to reach the internet via the inline allowlist proxy |
282+
| `SANDBOX_EGRESS_MODE` | `allowlist` | Egress mode: `allowlist` or `public_https` |
280283
| `SANDBOX_EGRESS_PORT` | `18443` | Port the inline egress proxy binds to on `127.0.0.1` |
281284
| `SANDBOX_EGRESS_ALLOWLIST` | (registries default) | Comma-separated list of additional hostnames the proxy permits |
282285
| `SKILL_DEPS_PATH` | `/opt/skill-deps` | Host-side directory mounted into every sandbox so install caches compound across runs |

src/config/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import secrets
2222
from pathlib import Path
23-
from typing import Any, Dict, List, Optional
23+
from typing import Any, Dict, List, Literal, Optional
2424

2525
import structlog
2626
from pydantic import Field, validator
@@ -111,6 +111,15 @@ class Settings(BaseSettings):
111111
"everything else is refused."
112112
),
113113
)
114+
sandbox_egress_mode: Literal["allowlist", "public_https"] = Field(
115+
default="allowlist",
116+
description=(
117+
"Sandbox egress proxy mode. 'allowlist' permits default package "
118+
"registries plus SANDBOX_EGRESS_ALLOWLIST hosts. 'public_https' "
119+
"permits arbitrary public HTTPS hosts while still blocking "
120+
"private, loopback, link-local, reserved, and multicast IPs."
121+
),
122+
)
114123
sandbox_egress_port: int = Field(
115124
default=18443,
116125
ge=1024,

src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ async def _startup_egress_proxy(app: FastAPI) -> None:
177177
proxy = EgressProxy(
178178
port=settings.sandbox_egress_port,
179179
allowlist=list(DEFAULT_ALLOWLIST) + extra,
180+
allow_public_https=settings.sandbox_egress_mode == "public_https",
180181
)
181182
await proxy.start()
182183
app.state.egress_proxy = proxy
@@ -207,6 +208,7 @@ async def _startup_egress_proxy(app: FastAPI) -> None:
207208
logger.info(
208209
"Sandbox network access ENABLED via egress proxy + firewall",
209210
port=settings.sandbox_egress_port,
211+
egress_mode=settings.sandbox_egress_mode,
210212
skill_deps_path=str(deps_root),
211213
sandbox_uid=sandbox_uid,
212214
allowlist_extra=extra or None,

src/services/sandbox/egress_proxy.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
- Refuses to open tunnels to private IP ranges (RFC 1918, loopback, link-local)
1111
even if a public hostname resolves to one. This stops trivial SSRF against
1212
Redis/S3/etc. on the same docker network.
13-
- Refuses any request whose host doesn't match the allowlist.
13+
- Refuses any request whose host doesn't match the allowlist, unless
14+
public HTTPS mode is explicitly enabled.
1415
1516
Allowlist defaults cover Python (PyPI), Node (npmjs), Go modules, and
1617
Rust crates so `pip install`, `npm install`, `go get`, `cargo add` work
17-
out of the box. Add more via SANDBOX_EGRESS_ALLOWLIST=host1,host2.
18+
out of the box. Add more via SANDBOX_EGRESS_ALLOWLIST=host1,host2. Operators
19+
can set SANDBOX_EGRESS_MODE=public_https to permit arbitrary public HTTPS
20+
hosts while retaining private/link-local IP blocking.
1821
"""
1922

2023
from __future__ import annotations
@@ -145,9 +148,11 @@ def __init__(
145148
port: int,
146149
allowlist: Iterable[str] = DEFAULT_ALLOWLIST,
147150
bind_host: str = "127.0.0.1",
151+
allow_public_https: bool = False,
148152
):
149153
self.port = port
150154
self.bind_host = bind_host
155+
self.allow_public_https = allow_public_https
151156
self.allowlist: Set[str] = {h.strip().lower() for h in allowlist if h.strip()}
152157
self._server: Optional[asyncio.base_events.Server] = None
153158
self._serve_task: Optional[asyncio.Task] = None
@@ -164,6 +169,7 @@ async def start(self) -> None:
164169
logger.info(
165170
"Sandbox egress proxy started",
166171
bind=f"{self.bind_host}:{self.port}",
172+
mode="public_https" if self.allow_public_https else "allowlist",
167173
allowlist_size=len(self.allowlist),
168174
)
169175

@@ -235,15 +241,24 @@ async def _handle_client(
235241
return
236242
host = _normalize_host(host)
237243

238-
# Allowlist check on the host *before* we resolve it, so audit logs show
239-
# the requested host even when DNS would have failed.
244+
# Host checks run *before* DNS so audit logs show the requested host even
245+
# when resolution would fail.
240246
if _is_private_ip(host):
241247
logger.warning(
242248
"Egress proxy refused private IP literal", host=host, peer=peer
243249
)
244250
await self._reply_and_close(client_writer, 403, "Forbidden")
245251
return
246-
if not _matches_allowlist(host, self.allowlist):
252+
if self.allow_public_https and port != 443:
253+
logger.warning(
254+
"Egress proxy refused public HTTPS request on non-HTTPS port",
255+
host=host,
256+
port=port,
257+
peer=peer,
258+
)
259+
await self._reply_and_close(client_writer, 403, "Forbidden")
260+
return
261+
if not self.allow_public_https and not _matches_allowlist(host, self.allowlist):
247262
logger.warning(
248263
"Egress proxy refused non-allowlisted host", host=host, peer=peer
249264
)

tests/unit/test_egress_proxy.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,63 @@ async def test_private_ip_literal_returns_403():
118118
await proxy.stop()
119119

120120

121+
@pytest.mark.asyncio
122+
async def test_public_https_mode_accepts_non_allowlisted_host():
123+
"""Public HTTPS mode should permit arbitrary public hostnames.
124+
125+
The test uses an unresolvable hostname so a successful allow check produces
126+
502 from DNS resolution, not 403 from the allowlist.
127+
"""
128+
port = _free_port()
129+
proxy = EgressProxy(
130+
port=port,
131+
allowlist={"good.test"},
132+
allow_public_https=True,
133+
)
134+
await proxy.start()
135+
try:
136+
status, _r, w = await _send_connect(port, "arbitrary-public-api.test:443")
137+
w.close()
138+
assert b"403" not in status, status
139+
assert b"502" in status, status
140+
finally:
141+
await proxy.stop()
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_public_https_mode_still_rejects_private_ip_literal():
146+
port = _free_port()
147+
proxy = EgressProxy(
148+
port=port,
149+
allowlist={"good.test"},
150+
allow_public_https=True,
151+
)
152+
await proxy.start()
153+
try:
154+
status, _r, w = await _send_connect(port, "10.0.0.1:443")
155+
w.close()
156+
assert b"403" in status, status
157+
finally:
158+
await proxy.stop()
159+
160+
161+
@pytest.mark.asyncio
162+
async def test_public_https_mode_rejects_non_https_ports():
163+
port = _free_port()
164+
proxy = EgressProxy(
165+
port=port,
166+
allowlist={"good.test"},
167+
allow_public_https=True,
168+
)
169+
await proxy.start()
170+
try:
171+
status, _r, w = await _send_connect(port, "arbitrary-public-api.test:22")
172+
w.close()
173+
assert b"403" in status, status
174+
finally:
175+
await proxy.stop()
176+
177+
121178
@pytest.mark.asyncio
122179
async def test_loopback_literal_returns_403():
123180
port = _free_port()

0 commit comments

Comments
 (0)