Module 4 · 6 min read
Containment: sandboxes, egress control and server-side request forgery
The controls that actually bound the damage when model-generated code or model-chosen URLs do something hostile, and why screening the code first is not one of them.
You are hardening a code interpreter that executes model-generated Python for many users, and there is budget for one control before the next release. The candidates are static analysis of the generated code before execution, deny-by-default network egress with an explicit allowlist, cutting the execution timeout, and logging every executed cell to a security monitoring platform. The one that most reduces blast radius is egress control, and the reasoning generalises well beyond interpreters.
Think about what turns a contained problem into a breach. Code that runs, misbehaves and cannot talk to anything has wasted some compute. The same code with unrestricted outbound access can read whatever the environment can reach, post it anywhere, pull down a second stage, and hold an interactive channel to an operator. Egress is the path for exfiltration and for command and control, and closing it by default converts most exploitation into a failed attempt. A shorter timeout limits one dimension of one class of abuse. Logging is retrospective: valuable for response, worthless for prevention. Static analysis of generated code is the weakest of the four because the adversary is a model that can produce unlimited semantically equivalent variants of any pattern you block, so screening buys you a filter that stops the naive case and creates confidence you have not earned.
Baseline sandbox hardening
For a sandbox serving many users, the load-bearing controls are isolation, egress denial and hard resource ceilings, and they work together: isolation stops users reaching each other, egress control stops data leaving, and quotas stop one execution consuming the service. Concretely, that means a fresh, unprivileged, ephemeral execution environment per session that is destroyed afterwards, so nothing persists between users and no state accumulates for an attacker to find. Deny-by-default egress with a narrow allowlist for genuinely required destinations. And hard quotas on CPU, memory, process count, file descriptors, disk and wall-clock time per execution, which contain merely buggy generated code as effectively as malicious code, and buggy is far more common.
Two designs to reject. Pattern-based static screening as the primary line of defence, for the reasons above; it is acceptable as a cheap extra layer, never as the boundary. And running interpreter workloads inside the main application pod to reuse its existing network policy: that policy exists to let your application reach your databases and internal services, which is precisely the reach you are trying to deny, and it puts hostile code inside the blast radius of the application's own identity, mounted secrets and service account.
Sandbox posture that holds
- Fresh unprivileged environment per session, destroyed afterwards
- Its own network namespace with deny-by-default egress
- No mounted credentials, no cloud metadata reachability, no control plane access
- Hard CPU, memory, process, descriptor, disk and wall-clock ceilings
- Static screening kept as a cheap extra layer, never as the boundary
Convenient and unsafe
- A long-lived shared worker reused across users
- Runs in the main application pod to inherit its network policy
- Holds the application service account so it can reach internal services
- Only a timeout, on the theory that runaway code is the real risk
- Pattern screening of generated code treated as the primary control
Check yourself
A sandbox has deny-by-default egress, but the allowlist was extended to the cloud instance metadata endpoint because a library requested it during start-up. Why is that a serious weakening?
Instance metadata services typically hand out credentials for the identity attached to the host. Model-generated code that can reach that endpoint can request those credentials and then act with the host's privileges against everything they cover, entirely inside your allowlist and without any outbound traffic to an obviously suspicious destination.
SSRF when the model chooses the URL
A feature lets the model fetch and summarise any URL a user supplies. This is server-side request forgery with an extra twist: the URL may be chosen not by the user directly but by a model that read an attacker's document, so the request looks entirely legitimate to your application. Effective defence operates at connection time, not on the string.
- Constrain the scheme firstAccept only HTTP and HTTPS. Everything else, including file, gopher and data schemes, is rejected before any name resolution happens.
- Resolve the hostname yourselfPerform the lookup explicitly so you hold the resulting addresses, rather than handing a name to a client library and hoping it checked.
- Validate every resulting addressReject loopback, private, link-local, unique-local and other reserved ranges. Validate all addresses returned, not just the first, since a name can resolve to several.
- Pin the address for the connectionConnect to the address you validated rather than re-resolving the name. This closes the gap between check and connect that DNS rebinding exploits.
- Re-validate on every redirect hopDo not let the client follow redirects on its own. Take each redirect target through the whole procedure again, and cap the number of hops.
- Enforce the same policy at an egress proxyRoute all model-initiated fetches through a proxy that owns the destination policy, so a code path that forgets the helper still fails closed.
- Bound the responseCap size and time, strip credentials and internal headers from the outbound request, and treat the fetched body as untrusted content when it enters context.
# Validate, pin, and re-check every hop. The proxy enforces the same policy
# independently, so a code path that forgets this helper still fails closed.
def safe_fetch(url, max_hops=3, budget_bytes=2_000_000):
for hop in range(max_hops):
parsed = require_scheme(url, allowed={"http", "https"})
ips = resolve_all(parsed.host) # a name can resolve to several addresses
if any(is_reserved(i) for i in ips): # loopback, private, link-local, unique-local
raise Blocked(parsed.host)
# pin a validated address: re-resolving here would reopen the rebinding window
resp = connect_to_ip(ips[0], parsed, follow_redirects=False,
max_bytes=budget_bytes)
if not resp.is_redirect:
return resp
url = resp.location # loop re-validates the new destination
raise Blocked("redirect limit")