feat: add dlp: false passthrough option for egress routes
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 23s
lint / lint (push) Successful in 1m1s
test / unit (pull_request) Successful in 48s
test / integration-firecracker (pull_request) Successful in 3m52s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 23s
lint / lint (push) Successful in 1m1s
test / unit (pull_request) Successful in 48s
test / integration-firecracker (pull_request) Successful in 3m52s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
Routes with `dlp: false` skip all DLP scanning (including CRLF injection, which cannot be disabled via `dlp.outbound_detectors: false`) and tunnel HTTPS connections without TLS interception so the client sees the server's real certificate. Fixes Docker image pulls, which fail when the proxy MITM's the TLS handshake and the container doesn't trust the per-bottle CA. Closes #462
This commit is contained in:
+54
-11
@@ -97,10 +97,11 @@ class EgressAddon:
|
||||
# comes from the orchestrator's /resolve (PRD 0070); there is no static
|
||||
# per-bottle routes file, SIGHUP reload, or single-tenant fallback.
|
||||
_resolver: "PolicyResolver"
|
||||
# Class default so __new__-built addons have it (real runs get a fresh
|
||||
# per-instance dict in __init__; only http_connect mutates it, which the
|
||||
# request-flow tests don't exercise).
|
||||
# Class defaults so __new__-built addons have them (real runs get fresh
|
||||
# per-instance collections in __init__; only http_connect mutates them,
|
||||
# which request-flow tests don't exercise unless they call http_connect).
|
||||
_conn_tokens: "dict[str, str]" = {}
|
||||
_passthrough_conns: "set[str]" = set()
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Resolver-only: the gateway is always multi-tenant, resolving each
|
||||
@@ -125,6 +126,10 @@ class EgressAddon:
|
||||
# `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped
|
||||
# inner requests). Keyed by client_conn.id; cleared on disconnect.
|
||||
self._conn_tokens: dict[str, str] = {}
|
||||
# Connections whose route carries `dlp: false` — mitmproxy tunnels
|
||||
# these without TLS interception so the client sees the server's real
|
||||
# cert. Keyed by client_conn.id; cleared on disconnect.
|
||||
self._passthrough_conns: set[str] = set()
|
||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||
|
||||
@staticmethod
|
||||
@@ -305,17 +310,53 @@ class EgressAddon:
|
||||
def http_connect(self, flow: http.HTTPFlow) -> None:
|
||||
"""Capture the identity token from an HTTPS tunnel's CONNECT (the inner
|
||||
bumped requests won't carry `Proxy-Authorization`), keyed by client
|
||||
connection, and strip it so it never reaches upstream."""
|
||||
connection, and strip it so it never reaches upstream.
|
||||
|
||||
For `dlp: false` routes, also resolve the policy here to make the
|
||||
allowlist decision before the TLS handshake: the tunnel is either
|
||||
blocked immediately or marked for passthrough in `_passthrough_conns`
|
||||
so `tls_clienthello` skips interception."""
|
||||
token = _token_from_proxy_auth(
|
||||
flow.request.headers.get("Proxy-Authorization", ""))
|
||||
flow.request.headers.pop("Proxy-Authorization", None)
|
||||
conn = flow.client_conn
|
||||
if conn is not None and getattr(conn, "id", ""):
|
||||
self._conn_tokens[conn.id] = token
|
||||
conn_id = getattr(conn, "id", "") if conn is not None else ""
|
||||
if conn_id:
|
||||
self._conn_tokens[conn_id] = token
|
||||
|
||||
# Resolve once to check if this host is a dlp: false route. For
|
||||
# non-passthrough hosts nothing changes — the allowlist check happens
|
||||
# in request() as normal. For passthrough hosts we must decide here
|
||||
# because the inner requests never reach request() after the bypass.
|
||||
client_ip = conn.peername[0] if conn is not None and conn.peername else ""
|
||||
config, _slug, env = resolve_client_context(self._resolver, client_ip, token)
|
||||
host = flow.request.pretty_host
|
||||
route = match_route(config.routes, host)
|
||||
if route is not None and route.dlp_passthrough:
|
||||
decision = decide(config.routes, host, "/", env, deny_reason=config.deny_reason)
|
||||
if decision.action == "block":
|
||||
flow.response = http.Response.make(
|
||||
403,
|
||||
decision.reason.encode("utf-8"),
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
return
|
||||
if conn_id:
|
||||
self._passthrough_conns.add(conn_id)
|
||||
|
||||
def tls_clienthello(self, client_hello: typing.Any) -> None:
|
||||
"""Skip TLS interception for `dlp: false` routes so the client sees
|
||||
the server's real certificate rather than the MITM CA's leaf."""
|
||||
conn_id = getattr(client_hello.context.client, "id", "")
|
||||
if conn_id in self._passthrough_conns:
|
||||
client_hello.ignore_connection = True
|
||||
|
||||
def client_disconnected(self, client: typing.Any) -> None:
|
||||
"""Drop the per-connection token when the client goes away."""
|
||||
self._conn_tokens.pop(getattr(client, "id", ""), None)
|
||||
"""Drop the per-connection token and passthrough flag when the client
|
||||
goes away."""
|
||||
conn_id = getattr(client, "id", "")
|
||||
self._conn_tokens.pop(conn_id, None)
|
||||
self._passthrough_conns.discard(conn_id)
|
||||
|
||||
async def request(self, flow: http.HTTPFlow) -> None:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
@@ -335,8 +376,10 @@ class EgressAddon:
|
||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||
# agent tried to smuggle in any header, path, query param, or body.
|
||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||
# `dlp: false` routes skip scanning entirely (TLS is also not
|
||||
# intercepted for HTTPS, so this branch only fires for plain HTTP).
|
||||
route = match_route(config.routes, flow.request.pretty_host)
|
||||
if route is not None:
|
||||
if route is not None and not route.dlp_passthrough:
|
||||
if not await self._handle_outbound_dlp(flow, route, slug, env):
|
||||
return
|
||||
# The redact policy may have rewritten the request line; recompute
|
||||
@@ -606,7 +649,7 @@ class EgressAddon:
|
||||
bottle's resolved config (`request()` stashed it — see `_flow_ctx`)."""
|
||||
config, _slug, env = self._flow_ctx(flow)
|
||||
route = match_route(config.routes, flow.request.pretty_host)
|
||||
if route is None:
|
||||
if route is None or route.dlp_passthrough:
|
||||
return
|
||||
if flow.response is None:
|
||||
return
|
||||
@@ -652,7 +695,7 @@ class EgressAddon:
|
||||
return
|
||||
config, slug, env = self._flow_ctx(flow)
|
||||
route = match_route(config.routes, flow.request.pretty_host)
|
||||
if route is None:
|
||||
if route is None or route.dlp_passthrough:
|
||||
return
|
||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
||||
content = message.content.decode("utf-8", errors="replace")
|
||||
|
||||
@@ -79,6 +79,8 @@ class Route:
|
||||
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
||||
outbound_on_match: str = ""
|
||||
preserve_auth: bool = False
|
||||
# dlp: false — skip all scanning; HTTPS flows tunnel without TLS interception.
|
||||
dlp_passthrough: bool = False
|
||||
|
||||
|
||||
LOG_OFF = 0 # no logging
|
||||
@@ -305,7 +307,7 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
)
|
||||
|
||||
# dlp detectors
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = parse_dlp_block(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match, dlp_passthrough = parse_dlp_block(
|
||||
idx, host, raw_dict,
|
||||
)
|
||||
|
||||
@@ -333,6 +335,7 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
inbound_detectors=inbound_detectors,
|
||||
outbound_on_match=outbound_on_match,
|
||||
preserve_auth=preserve_auth,
|
||||
dlp_passthrough=dlp_passthrough,
|
||||
)
|
||||
|
||||
|
||||
@@ -376,15 +379,18 @@ def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
||||
if r.git_fetch:
|
||||
d["git"] = {"fetch": True}
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
if dlp:
|
||||
d["dlp"] = dlp
|
||||
if r.dlp_passthrough:
|
||||
d["dlp"] = False
|
||||
else:
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
if dlp:
|
||||
d["dlp"] = dlp
|
||||
if r.preserve_auth:
|
||||
d["preserve_auth"] = True
|
||||
return d
|
||||
@@ -758,6 +764,8 @@ def scan_outbound(
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
crlf_text: str | None = None,
|
||||
) -> ScanResult | None:
|
||||
if route.dlp_passthrough:
|
||||
return None
|
||||
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
||||
# at import time (the gateway copies it flat alongside this file).
|
||||
try:
|
||||
@@ -855,6 +863,8 @@ def scan_inbound(
|
||||
route: Route,
|
||||
body: str | bytes,
|
||||
) -> ScanResult | None:
|
||||
if route.dlp_passthrough:
|
||||
return None
|
||||
try:
|
||||
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
|
||||
@@ -30,15 +30,20 @@ def parse_dlp_block(
|
||||
idx: int,
|
||||
host: str,
|
||||
raw_dict: dict[str, object],
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str, bool]:
|
||||
"""Parse the optional `dlp` block on a route, returning
|
||||
(outbound_detectors, inbound_detectors, outbound_on_match)."""
|
||||
(outbound_detectors, inbound_detectors, outbound_on_match, passthrough).
|
||||
|
||||
`dlp: false` sets passthrough=True: all scanning is skipped and HTTPS
|
||||
connections are tunnelled without TLS interception."""
|
||||
dlp_raw = raw_dict.get("dlp")
|
||||
if dlp_raw is None:
|
||||
return None, None, ""
|
||||
return None, None, "", False
|
||||
label = f"route[{idx}] ({host})"
|
||||
if dlp_raw is False:
|
||||
return None, None, "", True
|
||||
if not isinstance(dlp_raw, dict):
|
||||
raise ValueError(f"{label}: 'dlp' must be an object")
|
||||
raise ValueError(f"{label}: 'dlp' must be false or an object")
|
||||
dlp = typing.cast(dict[str, object], dlp_raw)
|
||||
|
||||
def _parse_detector_field(
|
||||
@@ -89,4 +94,4 @@ def parse_dlp_block(
|
||||
f"are 'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound, on_match
|
||||
return outbound, inbound, on_match, False
|
||||
|
||||
@@ -72,6 +72,7 @@ class ManifestEgressRoute:
|
||||
InboundDetectors: tuple[str, ...] | None = None
|
||||
OutboundOnMatch: str = ""
|
||||
PreserveAuth: bool = False
|
||||
DlpPassthrough: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
|
||||
@@ -167,8 +168,9 @@ class ManifestEgressRoute:
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
outbound_on_match = ""
|
||||
dlp_passthrough = False
|
||||
if "dlp" in d:
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = _parse_dlp_block(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match, dlp_passthrough = _parse_dlp_block(
|
||||
label, d.get("dlp"),
|
||||
)
|
||||
|
||||
@@ -220,6 +222,7 @@ class ManifestEgressRoute:
|
||||
InboundDetectors=inbound_detectors,
|
||||
OutboundOnMatch=outbound_on_match,
|
||||
PreserveAuth=preserve_auth,
|
||||
DlpPassthrough=dlp_passthrough,
|
||||
)
|
||||
|
||||
|
||||
@@ -342,7 +345,13 @@ def _parse_header_match(
|
||||
def _parse_dlp_block(
|
||||
route_label: str,
|
||||
raw: object,
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str, bool]:
|
||||
"""Parse the `dlp` value on a route.
|
||||
|
||||
`dlp: false` is a full bypass: no scanning, HTTPS tunnelled without
|
||||
TLS interception. Returns (outbound, inbound, on_match, passthrough)."""
|
||||
if raw is False:
|
||||
return None, None, "", True
|
||||
label = f"{route_label} dlp"
|
||||
d = as_json_object(raw, label)
|
||||
|
||||
@@ -394,7 +403,7 @@ def _parse_dlp_block(
|
||||
f"'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound, on_match
|
||||
return outbound, inbound, on_match, False
|
||||
|
||||
|
||||
LOG_LEVELS = frozenset({0, 1, 2})
|
||||
|
||||
Reference in New Issue
Block a user