fix(egress-proxy): force traffic through pipelock + block unallowlisted hosts
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m5s

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:
2026-05-25 16:38:18 -04:00
parent 5dc33f3acc
commit f807ed1149
4 changed files with 59 additions and 33 deletions
+8 -6
View File
@@ -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(