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(