From f807ed11493d70620291ad546f427e9634da89f9 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 16:38:18 -0400 Subject: [PATCH] fix(egress-proxy): force traffic through pipelock + block unallowlisted hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues stopping the bottle's egress allowlist from being enforced: 1. mitmproxy was bypassing pipelock. We set HTTPS_PROXY=pipelock in the egress-proxy container's env, but mitmproxy is a proxy *server* — it does NOT honor HTTP(S)_PROXY env vars on its outbound side the way HTTP-client libraries do. All post-MITM traffic was going direct to the upstream, never touching pipelock's hostname allowlist or DLP scanner. Fix: use mitmproxy's `--mode upstream:URL` flag. The Dockerfile entrypoint now reads a new `EGRESS_PROXY_UPSTREAM_PROXY` env (set by `DockerEgressProxy.start` to the pipelock URL when pipelock is in the topology) and switches mitmdump to upstream-proxy mode. Standalone runs of the image without the env still get `--mode regular@9099` direct-to-upstream — useful for unit-test boots. Confirmed in the boot log: "HTTP(S) proxy (upstream mode) listening at *:9099." 2. egress-proxy was forwarding unrecognized hosts. The addon's `decide()` returned `Decision(action="forward")` whenever no route matched the request host, deferring to pipelock to gate. With #1 broken pipelock wasn't gating either; even with #1 fixed, defense-in-depth wants both layers enforcing. Fix: no-route-match → 403 with a "host not in allowlist" reason. The egress allowlist is now strictly the set of hosts declared in `bottle.egress_proxy.routes`; bare-pass routes (host with no auth, no path_allowlist) cover the passthrough case for hosts that just need reach. path_allowlist enforcement on matched routes is unchanged. Test updated: `test_no_matching_route_forwards` → `test_no_matching_route_blocks`. 364 unit tests pass. Co-Authored-By: Claude Opus 4.7 --- Dockerfile.egress-proxy | 33 +++++++++++--------- claude_bottle/backend/docker/egress_proxy.py | 25 ++++++++++----- claude_bottle/egress_proxy_addon_core.py | 20 +++++++++--- tests/unit/test_egress_proxy_addon_core.py | 14 +++++---- 4 files changed, 59 insertions(+), 33 deletions(-) diff --git a/Dockerfile.egress-proxy b/Dockerfile.egress-proxy index 8f75a18..c948077 100644 --- a/Dockerfile.egress-proxy +++ b/Dockerfile.egress-proxy @@ -44,20 +44,23 @@ USER mitmproxy EXPOSE 9099 # Entrypoint: -# - Build a combined upstream-trust bundle when -# EGRESS_PROXY_UPSTREAM_CA is set (the backend's start step -# sets it to the in-container pipelock-CA path). -# `--set ssl_verify_upstream_trusted_ca` REPLACES mitmproxy's -# default trust store with the file we point it at; if we just -# pointed it at pipelock's CA, mitmproxy would refuse any host -# pipelock passes through (api.anthropic.com etc.) because -# pipelock's CA doesn't sign the real upstream certs. So -# concatenate the system bundle + pipelock CA into one PEM and -# point mitmproxy at that — covers both pipelock-MITM'd and -# pipelock-passthrough hosts. -# - --mode regular@9099 → standard HTTP/HTTPS forward proxy. +# - Upstream proxy: when EGRESS_PROXY_UPSTREAM_PROXY is set, +# use mitmproxy's `--mode upstream:URL` to forward all +# post-MITM traffic through pipelock. (mitmproxy does NOT +# honor HTTPS_PROXY env vars on its outbound side — it's a +# proxy server, not a client.) Standalone runs without +# EGRESS_PROXY_UPSTREAM_PROXY fall back to `regular@9099` +# direct-to-upstream — useful for unit tests of the image. +# - Upstream trust: when EGRESS_PROXY_UPSTREAM_CA is set, build +# a combined trust bundle (system roots + pipelock CA) and +# point mitmproxy at it via +# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES +# mitmproxy's default trust store with the file we point it +# at — passing just pipelock's CA would break pipelock- +# passthrough hosts (api.anthropic.com etc.) where mitmproxy +# sees real upstream certs signed by public CAs. The combined +# bundle covers both pipelock-MITM'd and pipelock-passthrough +# hosts. # - -s /app/egress_proxy_addon.py → loads our addon, reads # /etc/egress-proxy/routes.yaml. -# Standalone runs (no EGRESS_PROXY_UPSTREAM_CA) skip the bundle -# build and use mitmproxy's default trust store. -ENTRYPOINT ["sh", "-c", "if [ -n \"$EGRESS_PROXY_UPSTREAM_CA\" ] && [ -f \"$EGRESS_PROXY_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_PROXY_UPSTREAM_CA\" > \"$COMBINED\"; exec mitmdump --mode regular@9099 --set ssl_verify_upstream_trusted_ca=\"$COMBINED\" -s /app/egress_proxy_addon.py; else exec mitmdump --mode regular@9099 -s /app/egress_proxy_addon.py; fi"] +ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_PROXY_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_CA\" ] && [ -f \"$EGRESS_PROXY_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_PROXY_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_proxy_addon.py"] diff --git a/claude_bottle/backend/docker/egress_proxy.py b/claude_bottle/backend/docker/egress_proxy.py index 99d6ba4..819b6b0 100644 --- a/claude_bottle/backend/docker/egress_proxy.py +++ b/claude_bottle/backend/docker/egress_proxy.py @@ -229,14 +229,25 @@ class DockerEgressProxy(EgressProxy): "--network-alias", EGRESS_PROXY_HOSTNAME, ] if route_via_pipelock: - # Route egress-proxy's outbound HTTPS through pipelock so - # the egress allowlist + DLP body scanner apply to its - # traffic on the egress-proxy → upstream leg. Pipelock - # MITMs each handshake with its per-bottle CA, which is - # docker-cp'd in below and pointed to via the - # EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally - # adds the matching --set flag). + # Route egress-proxy's outbound traffic through pipelock + # so the egress allowlist + DLP body scanner apply to + # the egress-proxy → upstream leg. Pipelock MITMs each + # handshake with its per-bottle CA, which is docker-cp'd + # in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA + # env (entrypoint conditionally adds the matching --set + # flag). + # + # EGRESS_PROXY_UPSTREAM_PROXY is the mechanism: mitmproxy + # does NOT honor HTTPS_PROXY env vars on its outbound + # side (it's a proxy server, not a client). The + # entrypoint reads this env and switches mitmdump to + # `--mode upstream:` so all post-MITM traffic + # CONNECTs to pipelock instead of going direct. The + # HTTPS/HTTP_PROXY env vars below are kept for any + # bundled client libraries (mitmproxy plugin requests, + # etc.) that might honor them — harmless if ignored. create_args.extend([ + "-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTP_PROXY={plan.pipelock_proxy_url}", "-e", "NO_PROXY=localhost,127.0.0.1", diff --git a/claude_bottle/egress_proxy_addon_core.py b/claude_bottle/egress_proxy_addon_core.py index 36bb341..d2bbfbf 100644 --- a/claude_bottle/egress_proxy_addon_core.py +++ b/claude_bottle/egress_proxy_addon_core.py @@ -190,20 +190,30 @@ def decide( """Pure decision: given a route table + request host + path + env, return what the addon should do with the request. - - No matching route → forward unchanged. Pipelock will - hostname-gate it downstream; egress-proxy does not need to - decide on hosts it doesn't recognise. + - No matching route → BLOCK. The route table is the bottle's + egress allowlist; defense-in-depth complements pipelock's + hostname gate on the downstream leg. A bottle that wants a + host reachable from the agent must declare a route for it + (bare-pass route — no `auth`, no `path_allowlist` — is fine + for hosts that just need passthrough). - Matching route with `path_allowlist` set, request path doesn't start with any of the allowed prefixes → block with a clear reason. - Matching route with an auth pair → forward + inject Authorization. Token comes from `environ[route.token_env]`; - missing/empty values 500 (route declared auth but the secret + missing/empty values block (route declared auth but the secret isn't here — operator misconfig). """ route = match_route(routes, request_host) if route is None: - return Decision(action="forward") + return Decision( + action="block", + reason=( + f"egress-proxy: host {request_host!r} is not in the " + f"bottle's egress_proxy.routes allowlist. Declare a " + f"route for it or remove the request." + ), + ) if route.path_allowlist: if not any(request_path.startswith(p) for p in route.path_allowlist): diff --git a/tests/unit/test_egress_proxy_addon_core.py b/tests/unit/test_egress_proxy_addon_core.py index c58837d..fd9de6e 100644 --- a/tests/unit/test_egress_proxy_addon_core.py +++ b/tests/unit/test_egress_proxy_addon_core.py @@ -152,13 +152,15 @@ class TestMatchRoute(unittest.TestCase): class TestDecide(unittest.TestCase): - def test_no_matching_route_forwards(self): - # Hostnames the operator didn't declare are not the - # egress-proxy's concern; pipelock's hostname allowlist gates - # them downstream. + def test_no_matching_route_blocks(self): + # Defense-in-depth: egress-proxy gates the bottle's allowlist + # too, not just pipelock. Any host the operator didn't declare + # in egress_proxy.routes is 403'd at egress-proxy before it + # ever reaches pipelock. d = decide((), "elsewhere.example", "/anything", {}) - self.assertEqual("forward", d.action) - self.assertIsNone(d.inject_authorization) + self.assertEqual("block", d.action) + self.assertIn("allowlist", d.reason) + self.assertIn("'elsewhere.example'", d.reason) def test_path_allowlist_match_forwards(self): d = decide(