feat(egress-proxy): retarget remediation flow (PRD 0017 chunk 3) #30

Merged
didericis merged 18 commits from egress-proxy-block-remediation into main 2026-05-25 20:34:24 -04:00
4 changed files with 59 additions and 33 deletions
Showing only changes of commit f807ed1149 - Show all commits
+18 -15
View File
@@ -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"]
+18 -7
View File
@@ -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",
+15 -5
View File
@@ -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):
+8 -6
View File
@@ -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(