fix(pipelock): allow agent->sidecar traffic via SSRF exception
test / unit (pull_request) Successful in 12s
test / integration (pull_request) Successful in 21s

The agent's HTTP_PROXY points at pipelock, so a request to
http://cred-proxy:9099/... arrives at pipelock; pipelock resolves
the host, sees an RFC1918 address (the bottle's internal Docker
network sits in 172.x), and 403's "SSRF blocked: cred-proxy
resolves to internal IP 172.20.0.4". Bypassing pipelock entirely
would also remove its body scanner from the agent->cred-proxy leg
— we want to keep that DLP coverage.

Pipelock has `ssrf.ip_allowlist` for exactly this: CIDRs that
override the built-in internal-IP block while api_allowlist + body
scanning + tls_interception keep firing.

Wiring:

- `pipelock_build_config` accepts `ssrf_ip_allowlist`; when
  non-empty, emits an `ssrf: { ip_allowlist: [...] }` block.
- `pipelock_render_yaml` renders that block.
- `PipelockProxyPlan` gains `internal_network_cidr`.
- New `network_inspect_cidr(name)` helper reads the Docker-assigned
  subnet via `docker network inspect`.
- launch.py: after `network_create_internal`, inspect the CIDR,
  re-render the yaml with `ssrf_ip_allowlist=(cidr,)`, overwrite
  the file in place; `DockerPipelockProxy.start` then docker-cp's
  the updated content. Prepare's initial render stays unchanged
  (CIDR isn't known yet at prepare time).

The exception scope is the bottle's own internal network only —
agent ↔ pipelock / git-gate / cred-proxy. Body scanning still
applies to the bytes flowing through pipelock; pipelock just no
longer treats those internal IPs as exfil targets.
This commit is contained in:
2026-05-24 13:39:27 -04:00
parent f4452b391d
commit 51b20340a9
4 changed files with 114 additions and 6 deletions
+32 -1
View File
@@ -18,13 +18,20 @@ from pathlib import Path
from typing import Callable, Generator from typing import Callable, Generator
from ...log import die, info from ...log import die, info
from ...pipelock import pipelock_build_config, pipelock_render_yaml
from . import network as network_mod from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .cred_proxy import DockerCredProxy from .cred_proxy import DockerCredProxy
from .git_gate import DockerGitGate from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init from .pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
DockerPipelockProxy,
pipelock_proxy_url,
pipelock_tls_init,
)
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
@@ -68,6 +75,14 @@ def launch(
egress_network = network_mod.network_create_egress(plan.slug) egress_network = network_mod.network_create_egress(plan.slug)
stack.callback(network_mod.network_remove, egress_network) stack.callback(network_mod.network_remove, egress_network)
# Docker assigns a CIDR to the new internal network. Pipelock's
# SSRF guard otherwise rejects any destination resolving into
# RFC1918 space — which includes the cred-proxy / git-gate /
# pipelock sidecars themselves. Allowlist the bottle's own
# internal subnet so the agent can reach its sidecars via
# pipelock; api_allowlist + body-scanning still apply.
internal_cidr = network_mod.network_inspect_cidr(internal_network)
# Per-bottle ephemeral CA for pipelock's TLS interception # Per-bottle ephemeral CA for pipelock's TLS interception
# (PRD 0006). One-shot pipelock container writes ca.pem + # (PRD 0006). One-shot pipelock container writes ca.pem +
# ca-key.pem under plan.stage_dir; .start docker-cp's them # ca-key.pem under plan.stage_dir; .start docker-cp's them
@@ -75,9 +90,25 @@ def launch(
# stage dir, which start.py's outer finally `shutil.rmtree`s # stage dir, which start.py's outer finally `shutil.rmtree`s
# after the sidecar is torn down. # after the sidecar is torn down.
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir) ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
# Re-render the pipelock yaml with the SSRF allowlist now that
# we know the internal CIDR. Prepare wrote the yaml without
# the ssrf block (CIDR wasn't known yet); overwrite the same
# path so .start docker-cp's the updated content.
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
cfg = pipelock_build_config(
bottle,
ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER,
ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER,
ssrf_ip_allowlist=(internal_cidr,),
)
plan.proxy_plan.yaml_path.write_text(pipelock_render_yaml(cfg))
plan.proxy_plan.yaml_path.chmod(0o600)
proxy_plan = dataclasses.replace( proxy_plan = dataclasses.replace(
plan.proxy_plan, plan.proxy_plan,
internal_network=internal_network, internal_network=internal_network,
internal_network_cidr=internal_cidr,
egress_network=egress_network, egress_network=egress_network,
ca_cert_host_path=ca_cert_host, ca_cert_host_path=ca_cert_host,
ca_key_host_path=ca_key_host, ca_key_host_path=ca_key_host,
+23
View File
@@ -81,6 +81,29 @@ def network_create_egress(slug: str) -> str:
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
def network_inspect_cidr(name: str) -> str:
"""Return the IPv4 CIDR Docker assigned to a user-defined network.
Used by pipelock's SSRF guard exception: the bottle's internal
network sits in RFC1918 space, so pipelock's `internal:` list
would block any agent request whose destination resolves there
— including the cred-proxy sidecar's address. Adding the
network's CIDR to pipelock's `ssrf.ip_allowlist` lets traffic
targeted at the bottle's own sidecars through while pipelock
still body-scans and api_allowlist-gates as usual."""
result = subprocess.run(
["docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"docker network inspect {name} failed: {result.stderr.strip()}")
cidr = result.stdout.strip()
if not cidr:
die(f"network {name!r} has no IPAM subnet configured")
return cidr
def network_attach(network: str, container: str) -> None: def network_attach(network: str, container: str) -> None:
result = subprocess.run( result = subprocess.run(
["docker", "network", "connect", network, container], ["docker", "network", "connect", network, container],
+32 -5
View File
@@ -152,6 +152,7 @@ def pipelock_build_config(
*, *,
ca_cert_path: str = "", ca_cert_path: str = "",
ca_key_path: str = "", ca_key_path: str = "",
ssrf_ip_allowlist: tuple[str, ...] = (),
) -> dict[str, object]: ) -> dict[str, object]:
"""Build the structured pipelock config dict the sidecar will load. """Build the structured pipelock config dict the sidecar will load.
@@ -166,7 +167,17 @@ def pipelock_build_config(
Pass both or neither: both → emit `tls_interception` block with Pass both or neither: both → emit `tls_interception` block with
`enabled: true`; neither → omit the block entirely (pipelock `enabled: true`; neither → omit the block entirely (pipelock
falls back to its built-in default of `enabled: false`). Used falls back to its built-in default of `enabled: false`). Used
by PRD 0006 to turn on pipelock's native TLS interception.""" by PRD 0006 to turn on pipelock's native TLS interception.
`ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
destinations by default, which would catch the agent's
cred-proxy traffic (cred-proxy sits on the bottle's internal
Docker network in 172.x space). Pass the bottle's internal
network CIDR here so `cred-proxy:9099` requests get through
pipelock while api_allowlist + body-scanning still apply. Empty
by default; omitted from the rendered yaml when empty so
pipelock keeps its built-in SSRF defaults."""
cfg: dict[str, object] = { cfg: dict[str, object] = {
"version": 1, "version": 1,
"mode": "strict", "mode": "strict",
@@ -193,6 +204,8 @@ def pipelock_build_config(
"ca_key": ca_key_path, "ca_key": ca_key_path,
"passthrough_domains": pipelock_effective_tls_passthrough(bottle), "passthrough_domains": pipelock_effective_tls_passthrough(bottle),
} }
if ssrf_ip_allowlist:
cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)}
return cfg return cfg
@@ -236,6 +249,13 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
lines.append(" passthrough_domains:") lines.append(" passthrough_domains:")
for d in passthrough: for d in passthrough:
lines.append(f' - "{d}"') lines.append(f' - "{d}"')
if "ssrf" in cfg:
lines.append("")
lines.append("ssrf:")
ssrf = cast(dict[str, object], cfg["ssrf"])
lines.append(" ip_allowlist:")
for ip in cast(list[str], ssrf["ip_allowlist"]):
lines.append(f' - "{ip}"')
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -252,14 +272,21 @@ class PipelockProxyPlan:
already so it doesn't need the host paths to be valid). The already so it doesn't need the host paths to be valid). The
remaining fields are populated by the backend's launch step remaining fields are populated by the backend's launch step
via `dataclasses.replace`: internal/egress networks once via `dataclasses.replace`: internal/egress networks once
those networks exist, and the CA host paths once the those networks exist, the CA host paths once the one-shot
one-shot `pipelock tls init` has run. Empty defaults are `pipelock tls init` has run, and `internal_network_cidr` once
sentinels meaning "not yet set"; `.start` validates that Docker has assigned a subnet to the internal network. Empty
they are populated.""" defaults are sentinels meaning "not yet set"; `.start` validates
that they are populated.
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
so the agent's requests at `cred-proxy:9099` (or any other
bottle-internal sidecar) bypass pipelock's RFC1918 SSRF guard
while api_allowlist and body-scanning still apply."""
yaml_path: Path yaml_path: Path
slug: str slug: str
internal_network: str = "" internal_network: str = ""
internal_network_cidr: str = ""
egress_network: str = "" egress_network: str = ""
ca_cert_host_path: Path = Path() ca_cert_host_path: Path = Path()
ca_key_host_path: Path = Path() ca_key_host_path: Path = Path()
+27
View File
@@ -77,6 +77,21 @@ class TestBuildConfig(unittest.TestCase):
ca_cert_path="/etc/pipelock-ca.pem", ca_cert_path="/etc/pipelock-ca.pem",
) )
def test_ssrf_block_omitted_when_no_allowlist(self):
cfg = pipelock_build_config(fixture_minimal().bottles["dev"])
self.assertNotIn("ssrf", cfg)
def test_ssrf_block_emitted_when_allowlist_supplied(self):
# The bottle's internal Docker subnet lands here at launch
# time so cred-proxy:9099 (172.x.x.x) doesn't trip pipelock's
# RFC1918 SSRF guard.
cfg = pipelock_build_config(
fixture_minimal().bottles["dev"],
ssrf_ip_allowlist=("172.20.0.0/16",),
)
self.assertIn("ssrf", cfg)
self.assertEqual({"ip_allowlist": ["172.20.0.0/16"]}, cfg["ssrf"])
class TestRenderAndWrite(unittest.TestCase): class TestRenderAndWrite(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -148,6 +163,18 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertIn("passthrough_domains:", content) self.assertIn("passthrough_domains:", content)
self.assertIn('- "api.anthropic.com"', content) self.assertIn('- "api.anthropic.com"', content)
def test_render_emits_ssrf_block_when_allowlist_given(self):
cfg = pipelock_build_config(
fixture_minimal().bottles["dev"],
ca_cert_path="/etc/pipelock-ca.pem",
ca_key_path="/etc/pipelock-ca-key.pem",
ssrf_ip_allowlist=("172.20.0.0/16",),
)
text = pipelock_render_yaml(cfg)
self.assertIn("ssrf:", text)
self.assertIn("ip_allowlist:", text)
self.assertIn('- "172.20.0.0/16"', text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()