fix(egress-proxy): force traffic through pipelock + block unallowlisted hosts
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 <noreply@anthropic.com>
This commit is contained in:
+18
-15
@@ -44,20 +44,23 @@ USER mitmproxy
|
|||||||
EXPOSE 9099
|
EXPOSE 9099
|
||||||
|
|
||||||
# Entrypoint:
|
# Entrypoint:
|
||||||
# - Build a combined upstream-trust bundle when
|
# - Upstream proxy: when EGRESS_PROXY_UPSTREAM_PROXY is set,
|
||||||
# EGRESS_PROXY_UPSTREAM_CA is set (the backend's start step
|
# use mitmproxy's `--mode upstream:URL` to forward all
|
||||||
# sets it to the in-container pipelock-CA path).
|
# post-MITM traffic through pipelock. (mitmproxy does NOT
|
||||||
# `--set ssl_verify_upstream_trusted_ca` REPLACES mitmproxy's
|
# honor HTTPS_PROXY env vars on its outbound side — it's a
|
||||||
# default trust store with the file we point it at; if we just
|
# proxy server, not a client.) Standalone runs without
|
||||||
# pointed it at pipelock's CA, mitmproxy would refuse any host
|
# EGRESS_PROXY_UPSTREAM_PROXY fall back to `regular@9099`
|
||||||
# pipelock passes through (api.anthropic.com etc.) because
|
# direct-to-upstream — useful for unit tests of the image.
|
||||||
# pipelock's CA doesn't sign the real upstream certs. So
|
# - Upstream trust: when EGRESS_PROXY_UPSTREAM_CA is set, build
|
||||||
# concatenate the system bundle + pipelock CA into one PEM and
|
# a combined trust bundle (system roots + pipelock CA) and
|
||||||
# point mitmproxy at that — covers both pipelock-MITM'd and
|
# point mitmproxy at it via
|
||||||
# pipelock-passthrough hosts.
|
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
|
||||||
# - --mode regular@9099 → standard HTTP/HTTPS forward proxy.
|
# 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
|
# - -s /app/egress_proxy_addon.py → loads our addon, reads
|
||||||
# /etc/egress-proxy/routes.yaml.
|
# /etc/egress-proxy/routes.yaml.
|
||||||
# Standalone runs (no EGRESS_PROXY_UPSTREAM_CA) skip the bundle
|
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"]
|
||||||
# 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"]
|
|
||||||
|
|||||||
@@ -229,14 +229,25 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
||||||
]
|
]
|
||||||
if route_via_pipelock:
|
if route_via_pipelock:
|
||||||
# Route egress-proxy's outbound HTTPS through pipelock so
|
# Route egress-proxy's outbound traffic through pipelock
|
||||||
# the egress allowlist + DLP body scanner apply to its
|
# so the egress allowlist + DLP body scanner apply to
|
||||||
# traffic on the egress-proxy → upstream leg. Pipelock
|
# the egress-proxy → upstream leg. Pipelock MITMs each
|
||||||
# MITMs each handshake with its per-bottle CA, which is
|
# handshake with its per-bottle CA, which is docker-cp'd
|
||||||
# docker-cp'd in below and pointed to via the
|
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA
|
||||||
# EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally
|
# env (entrypoint conditionally adds the matching --set
|
||||||
# adds the matching --set flag).
|
# 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:<URL>` 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([
|
create_args.extend([
|
||||||
|
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||||
|
|||||||
@@ -190,20 +190,30 @@ def decide(
|
|||||||
"""Pure decision: given a route table + request host + path + env,
|
"""Pure decision: given a route table + request host + path + env,
|
||||||
return what the addon should do with the request.
|
return what the addon should do with the request.
|
||||||
|
|
||||||
- No matching route → forward unchanged. Pipelock will
|
- No matching route → BLOCK. The route table is the bottle's
|
||||||
hostname-gate it downstream; egress-proxy does not need to
|
egress allowlist; defense-in-depth complements pipelock's
|
||||||
decide on hosts it doesn't recognise.
|
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
|
- Matching route with `path_allowlist` set, request path doesn't
|
||||||
start with any of the allowed prefixes → block with a clear
|
start with any of the allowed prefixes → block with a clear
|
||||||
reason.
|
reason.
|
||||||
- Matching route with an auth pair → forward + inject
|
- Matching route with an auth pair → forward + inject
|
||||||
Authorization. Token comes from `environ[route.token_env]`;
|
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).
|
isn't here — operator misconfig).
|
||||||
"""
|
"""
|
||||||
route = match_route(routes, request_host)
|
route = match_route(routes, request_host)
|
||||||
if route is None:
|
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 route.path_allowlist:
|
||||||
if not any(request_path.startswith(p) for p in route.path_allowlist):
|
if not any(request_path.startswith(p) for p in route.path_allowlist):
|
||||||
|
|||||||
@@ -152,13 +152,15 @@ class TestMatchRoute(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestDecide(unittest.TestCase):
|
class TestDecide(unittest.TestCase):
|
||||||
def test_no_matching_route_forwards(self):
|
def test_no_matching_route_blocks(self):
|
||||||
# Hostnames the operator didn't declare are not the
|
# Defense-in-depth: egress-proxy gates the bottle's allowlist
|
||||||
# egress-proxy's concern; pipelock's hostname allowlist gates
|
# too, not just pipelock. Any host the operator didn't declare
|
||||||
# them downstream.
|
# in egress_proxy.routes is 403'd at egress-proxy before it
|
||||||
|
# ever reaches pipelock.
|
||||||
d = decide((), "elsewhere.example", "/anything", {})
|
d = decide((), "elsewhere.example", "/anything", {})
|
||||||
self.assertEqual("forward", d.action)
|
self.assertEqual("block", d.action)
|
||||||
self.assertIsNone(d.inject_authorization)
|
self.assertIn("allowlist", d.reason)
|
||||||
|
self.assertIn("'elsewhere.example'", d.reason)
|
||||||
|
|
||||||
def test_path_allowlist_match_forwards(self):
|
def test_path_allowlist_match_forwards(self):
|
||||||
d = decide(
|
d = decide(
|
||||||
|
|||||||
Reference in New Issue
Block a user