Skip to content

feat(security): let operators un-block CIDRs behind a transparent proxy#1465

Open
connermo wants to merge 1 commit into
nextlevelbuilder:devfrom
connermo:feat/ssrf-operator-allowlist
Open

feat(security): let operators un-block CIDRs behind a transparent proxy#1465
connermo wants to merge 1 commit into
nextlevelbuilder:devfrom
connermo:feat/ssrf-operator-allowlist

Conversation

@connermo

Copy link
Copy Markdown

Problem

SSRF protection resolves a hostname and judges the resulting IP. That model assumes DNS resolution describes where the traffic actually goes — which stops being true behind a TUN / fake-IP proxy.

In fake-IP mode the proxy answers every DNS query with a synthetic address out of a reserved range, then routes that address to the real public host. The resolved IP is a handle, not a destination.

Observed on a machine behind such a proxy:

WARN tool error tool=web_fetch error="SSRF protection: hostname news.sina.cn resolves to private IP 198.18.0.236"
WARN tool error tool=web_fetch error="SSRF protection: hostname best-ai.org  resolves to private IP 198.18.0.234"

All three were verified reachable from the same container — HTTP 200 — so the network path was fine; only the check rejected them.

The block list is compiled in, so no configuration can fix this, and web_fetch is simply unusable. Worse, the agent keeps burning iterations retrying URLs that can never succeed: one turn took 85s with 15 tool calls, 3 of which were guaranteed failures.

Change

GOCLAW_SSRF_ALLOWED_CIDRS lets an operator name the ranges their proxy hands out:

GOCLAW_SSRF_ALLOWED_CIDRS=198.18.0.0/15
  • Empty by default — nothing changes for deployments that do not set it.
  • Accepted and refused entries are logged at startup. This widens what LLM- and admin-supplied URLs can reach, so it should be visible rather than silent.

Ranges that can never be allowlisted

Link-local (including cloud metadata at 169.254.169.254), multicast, and unspecified are refused at parse time — checked in both directions, so neither an exact entry nor a wider range that swallows one gets through. 0.0.0.0/0 is refused.

This mirrors what exemptableCIDRs already declines to cover:

an allowlist entry must never open a path to the cloud metadata endpoint

Verified against the running binary with a deliberately dangerous value:

WARN security.ssrf_allowlist          allowed=[198.18.0.0/15]
WARN security.ssrf_allowlist_rejected rejected="[169.254.0.0/16 (overlaps 169.254.0.0/16)]"

Two implementation notes

Applied inside isBlocked, not just validate(). NewSafeClient re-checks the pinned IP at dial time through the same function, so relaxing only the pre-flight check would pass validation and then fail to connect.

internal/tools has its own SSRF gate. web_shared.go carries a separate private-range list used by web_fetch / web_search, and the two lists are not identical (tools has 100.64.0.0/10 and fec0::/10; security has 224.0.0.0/4). It must consult the same operator setting, or relaxing one gate leaves the other rejecting exactly the traffic the operator permitted — this was caught during testing, when the first version of the change had no effect on web_fetch at all.

Unifying the two lists is deliberately not attempted here; it is a wider and riskier change. Flagging it as pre-existing debt: adding a range to one list today silently misses the other.

Tests

internal/security/ssrf_operator_allowlist_test.go (6 cases) and internal/tools/web_shared_ssrf_allowlist_test.go (2 cases):

  • empty / whitespace / comma-only specs are inert
  • the benchmarking range is accepted
  • link-local, cloud-metadata host, a wider range swallowing link-local, 0.0.0.0/0, multicast, unspecified, IPv6 link-local are all refused
  • malformed entries are reported, not silently dropped, while valid siblings still apply
  • isBlocked and Validate honour the allowlist while keeping metadata and RFC 1918 blocked
  • CheckSSRF (the tools gate) honours it too, and still blocks metadata / RFC 1918 / loopback

Verification

  • go build ./..., go build -tags sqliteonly ./..., go vet ./... — clean
  • go test -race green for security, tools, mcp
  • End-to-end against a live gateway: news.sina.cn fetched successfully; 169.254.169.254 and 10.0.0.1 still rejected

SSRF protection resolves a hostname and judges the resulting IP. That
model assumes DNS resolution describes where the traffic actually goes,
which stops being true behind a TUN/fake-IP proxy: every query is answered
with a synthetic address out of a reserved range, and the proxy then
routes that address to the real public host. The IP is a handle, not a
destination.

In that environment web_fetch rejects ordinary public sites — observed
with news.sina.cn resolving to 198.18.0.236 — and no configuration can
fix it, because the block list is compiled in. The agent then burns
iterations retrying URLs that can never succeed.

GOCLAW_SSRF_ALLOWED_CIDRS lets an operator name the ranges their proxy
hands out. Empty by default, so nothing changes for deployments that do
not set it, and the accepted and refused entries are both logged at
startup — this widens what LLM- and admin-supplied URLs can reach, so it
should be visible.

Ranges an SSRF actually targets can never be allowlisted: link-local
(including cloud metadata at 169.254.169.254), multicast and unspecified
are refused at parse time, in either direction, so neither an exact entry
nor a wider range that swallows one gets through.

Applied inside isBlocked rather than only in validate() because
NewSafeClient re-checks the pinned IP at dial time through the same
function — relaxing just the pre-flight check would pass validation and
then fail to connect.

internal/tools carries its own private-range list for web_fetch and
web_search, separate from this package and not identical to it. It has to
consult the same setting, or relaxing one gate leaves the other rejecting
the very traffic the operator permitted. Unifying the two lists is left
alone here; it is a wider change than this one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant