From 51b20340a91fec6435491c48f2ba153360273df7 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 24 May 2026 13:39:27 -0400 Subject: [PATCH] fix(pipelock): allow agent->sidecar traffic via SSRF exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- claude_bottle/backend/docker/launch.py | 33 +++++++++++++++++++++- claude_bottle/backend/docker/network.py | 23 +++++++++++++++ claude_bottle/pipelock.py | 37 +++++++++++++++++++++---- tests/unit/test_pipelock_yaml.py | 27 ++++++++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 407f925..c59fb7f 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -18,13 +18,20 @@ from pathlib import Path from typing import Callable, Generator from ...log import die, info +from ...pipelock import pipelock_build_config, pipelock_render_yaml from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan from .cred_proxy import DockerCredProxy 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 @@ -68,6 +75,14 @@ def launch( egress_network = network_mod.network_create_egress(plan.slug) 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 # (PRD 0006). One-shot pipelock container writes ca.pem + # 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 # after the sidecar is torn down. 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( plan.proxy_plan, internal_network=internal_network, + internal_network_cidr=internal_cidr, egress_network=egress_network, ca_cert_host_path=ca_cert_host, ca_key_host_path=ca_key_host, diff --git a/claude_bottle/backend/docker/network.py b/claude_bottle/backend/docker/network.py index 1d082d8..9cc0981 100644 --- a/claude_bottle/backend/docker/network.py +++ b/claude_bottle/backend/docker/network.py @@ -81,6 +81,29 @@ def network_create_egress(slug: str) -> str: 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: result = subprocess.run( ["docker", "network", "connect", network, container], diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 67234ce..c4f30cf 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -152,6 +152,7 @@ def pipelock_build_config( *, ca_cert_path: str = "", ca_key_path: str = "", + ssrf_ip_allowlist: tuple[str, ...] = (), ) -> dict[str, object]: """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 `enabled: true`; neither → omit the block entirely (pipelock 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] = { "version": 1, "mode": "strict", @@ -193,6 +204,8 @@ def pipelock_build_config( "ca_key": ca_key_path, "passthrough_domains": pipelock_effective_tls_passthrough(bottle), } + if ssrf_ip_allowlist: + cfg["ssrf"] = {"ip_allowlist": list(ssrf_ip_allowlist)} return cfg @@ -236,6 +249,13 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str: lines.append(" passthrough_domains:") for d in passthrough: 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" @@ -252,14 +272,21 @@ class PipelockProxyPlan: already so it doesn't need the host paths to be valid). The remaining fields are populated by the backend's launch step via `dataclasses.replace`: internal/egress networks once - those networks exist, and the CA host paths once the - one-shot `pipelock tls init` has run. Empty defaults are - sentinels meaning "not yet set"; `.start` validates that - they are populated.""" + those networks exist, the CA host paths once the one-shot + `pipelock tls init` has run, and `internal_network_cidr` once + Docker has assigned a subnet to the internal network. Empty + 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 slug: str internal_network: str = "" + internal_network_cidr: str = "" egress_network: str = "" ca_cert_host_path: Path = Path() ca_key_host_path: Path = Path() diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index caa1105..ac3bb36 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -77,6 +77,21 @@ class TestBuildConfig(unittest.TestCase): 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): def setUp(self): @@ -148,6 +163,18 @@ class TestRenderAndWrite(unittest.TestCase): self.assertIn("passthrough_domains:", 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__": unittest.main()