Filter Sandbox HTTPS traffic with a proxy Sidecar

A proxy Sidecar can inspect and restrict HTTPS requests made by otherwise proxy-unaware programs in a Sandbox. This example permits only GET requests to the modal-labs/modal-client repository on github.com, while forwarding requests to all other domains without applying the filter.

We use mitmproxy to terminate TLS, parse HTTP, connect to GitHub, and generate certificates. HTTPS traffic is encrypted, so to determine the request path the sidecar must be in the middle of the connection, and perform the encryption/decryption against both the original client and server instead.

import tempfile
from pathlib import Path

import modal

app = modal.App.lookup("example-sidecar-traffic-routing", create_if_missing=True)

SIDECAR_NAME = "proxy"
MITMPROXY_CONFIG_DIR = "/tmp/mitmproxy"
MITMPROXY_CA_CERT = f"{MITMPROXY_CONFIG_DIR}/mitmproxy-ca-cert.pem"
SANDBOX_CA_CERT = "/tmp/mitmproxy-ca-cert.pem"

Define the request policy 

To configure mitmproxy dynamically we use an addon script. The tls_clienthello hook is used to parse the hostname and port from the ClientHello SNI. After decrypting and parsing a request, the request hook applies the GitHub-specific request path policy.

MITMPROXY_ADDON = """\
from mitmproxy import http, tls

FILTERED_HOST = "github.com"
ALLOWED_REPOSITORY = "/modal-labs/modal-client"


def tls_clienthello(data: tls.ClientHelloData) -> None:
    hostname = data.client_hello.sni
    if hostname:
        data.context.server.address = (hostname, 443)
        data.context.server.sni = hostname


def request(flow: http.HTTPFlow) -> None:
    request = flow.request
    hostname = (flow.client_conn.sni or "").lower()
    if hostname != FILTERED_HOST:
        return

    raw_path = request.path.split("?", 1)[0]
    segments = raw_path.split("/")
    repository_segments = ["", *ALLOWED_REPOSITORY.strip("/").split("/")]
    # Reject alternate path spellings that GitHub could normalize after this check.
    allowed_path = (
        segments[: len(repository_segments)] == repository_segments
        and all(segment not in {".", ".."} for segment in segments)
        and "%" not in raw_path
        and "\\\\" not in raw_path
    )
    if (
        request.method == "GET"
        and request.pretty_host == FILTERED_HOST
        and allowed_path
    ):
        return

    flow.response = http.Response.make(
        403,
        b"This Sandbox may only GET github.com/modal-labs/modal-client.\\n",
        {"Content-Type": "text/plain"},
    )
"""

We install mitmproxy and copy the policy addon into its Image.

with tempfile.TemporaryDirectory() as tmp_dir:
    addon_path = Path(tmp_dir) / "github_filter.py"
    addon_path.write_text(MITMPROXY_ADDON)
    with modal.enable_output():
        sidecar_image = (
            modal.Image.debian_slim(python_version="3.12")
            .pip_install("mitmproxy==12.2.3")
            .add_local_file(addon_path, "/github_filter.py", copy=True)
            .build(app)
        )

sandbox_image = modal.Image.debian_slim().apt_install("curl")

Start the Sandbox and proxy Sidecar 

The experimental option names the Sidecar that receives all outbound TCP traffic on port 443. HTTPS fails closed until that Sidecar is running.

with modal.enable_output():
    sandbox = modal.Sandbox.create(
        "sleep",
        "600",
        app=app,
        image=sandbox_image,
        timeout=5 * 60,
        experimental_options={"proxy_traffic_via_sidecar": SIDECAR_NAME},
    )
print(f"Sandbox ID: {sandbox.object_id}")

sidecar = sandbox._experimental_sidecars.create(
    "mitmdump",
    "--mode",
    "reverse:https://invalid.invalid@443",
    "--set",
    f"confdir={MITMPROXY_CONFIG_DIR}",
    "--set",
    "connection_strategy=lazy",
    "--set",
    "keep_host_header=true",
    "--scripts",
    "/github_filter.py",
    name=SIDECAR_NAME,
    image=sidecar_image,
)
print(f"Sidecar ID: {sidecar.object_id}")

Trust the proxy’s certificate authority 

Mitmproxy creates a unique certificate authority on first startup. Copy only its public certificate into the main Sandbox and pass it to curl. The CA private key remains isolated in the Sidecar.

read_ca = sidecar.exec(
    "bash",
    "-c",
    f"until test -s {MITMPROXY_CA_CERT} "
    "&& (echo > /dev/tcp/127.0.0.1/443) 2>/dev/null; "
    f"do sleep 0.1; done; cat {MITMPROXY_CA_CERT}",
    timeout=1 * 60,
)
ca_certificate = read_ca.stdout.read()
if read_ca.wait() != 0:
    raise RuntimeError(read_ca.stderr.read())

write_ca = sandbox.exec("tee", SANDBOX_CA_CERT)
write_ca.stdin.write(ca_certificate)
write_ca.stdin.write_eof()
write_ca.stdin.drain()
if write_ca.wait() != 0:
    raise RuntimeError(write_ca.stderr.read())

Exercise the policy 

These requests use an ordinary GitHub URL with no explicit HTTP proxy settings. The first request reaches GitHub through the Sidecar. The next three are answered by the addon and never reach GitHub. The final request demonstrates that another domain is forwarded normally.

def request_status(method: str, url: str) -> str:
    process = sandbox.exec(
        "curl",
        "--cacert",
        SANDBOX_CA_CERT,
        "--request",
        method,
        "--path-as-is",
        "--silent",
        "--show-error",
        "--output",
        "/dev/null",
        "--write-out",
        "%{http_code}",
        url,
    )
    status = process.stdout.read()
    if process.wait() != 0:
        raise RuntimeError(process.stderr.read())
    return status


requests = [
    ("GET", "https://github.com/modal-labs/modal-client"),
    ("POST", "https://github.com/modal-labs/modal-client"),
    ("GET", "https://github.com/modal-labs/modal-examples"),
    ("GET", "https://github.com/modal-labs/modal-client/../modal-examples"),
    ("GET", "https://example.com/"),
]
for method, url in requests:
    print(f"{method} {url} -> {request_status(method, url)}")

The output should look like:

GET https://github.com/modal-labs/modal-client -> 200
POST https://github.com/modal-labs/modal-client -> 403
GET https://github.com/modal-labs/modal-examples -> 403
GET https://github.com/modal-labs/modal-client/../modal-examples -> 403
GET https://example.com/ -> 200

Terminating the Sandbox also terminates its Sidecars.

sandbox.terminate()