Files
goclaw/internal/security/ssrf_operator_allowlist_test.go
Conner MoandConner Mo c21499a7f5 feat(security): let operators un-block CIDRs behind a transparent proxy (#1465)
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.

Co-authored-by: Conner Mo <connermo@ConnerdeMacBook-Pro.local>
2026-07-31 21:30:01 +07:00

163 lines
5.3 KiB
Go

package security
import (
"net"
"strings"
"testing"
)
// The escape hatch must be inert unless an operator opts in — an empty or
// absent env var has to leave the shipped block list untouched.
func TestParseOperatorAllowedCIDRs_EmptyIsInert(t *testing.T) {
t.Parallel()
for _, spec := range []string{"", " ", ",", " , , "} {
nets, rejected := parseOperatorAllowedCIDRs(spec)
if len(nets) != 0 {
t.Fatalf("spec %q produced %d allowed nets, want 0", spec, len(nets))
}
if len(rejected) != 0 {
t.Fatalf("spec %q produced rejections %v, want none", spec, rejected)
}
}
}
// The case the hatch exists for: a TUN/fake-IP proxy hands out RFC 2544
// addresses that route to real public hosts.
func TestParseOperatorAllowedCIDRs_AcceptsBenchmarkingRange(t *testing.T) {
t.Parallel()
nets, rejected := parseOperatorAllowedCIDRs("198.18.0.0/15")
if len(rejected) != 0 {
t.Fatalf("unexpected rejections: %v", rejected)
}
if len(nets) != 1 {
t.Fatalf("got %d nets, want 1", len(nets))
}
if !nets[0].Contains(net.ParseIP("198.18.0.236")) {
t.Fatal("parsed net does not contain the address it was configured for")
}
}
// Cloud metadata is the canonical SSRF target. No operator setting may open a
// path to it — not directly, and not by way of a wider range that swallows it.
func TestParseOperatorAllowedCIDRs_RefusesProtectedRanges(t *testing.T) {
t.Parallel()
cases := []struct {
name string
spec string
}{
{"link-local exactly", "169.254.0.0/16"},
{"cloud metadata host", "169.254.169.254/32"},
{"a wider range swallowing link-local", "169.0.0.0/8"},
{"everything", "0.0.0.0/0"},
{"multicast", "224.0.0.0/4"},
{"unspecified", "0.0.0.0/32"},
{"ipv6 link-local", "fe80::/10"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nets, rejected := parseOperatorAllowedCIDRs(tc.spec)
if len(nets) != 0 {
t.Fatalf("%q was accepted; it must never be allowlistable", tc.spec)
}
if len(rejected) != 1 {
t.Fatalf("got %d rejections for %q, want 1", len(rejected), tc.spec)
}
if !strings.Contains(rejected[0], "overlaps") {
t.Fatalf("rejection %q does not explain the overlap", rejected[0])
}
})
}
}
// A rejected entry must be reported, never silently dropped — an operator who
// mistyped one range out of several would otherwise believe it took effect.
func TestParseOperatorAllowedCIDRs_ReportsBadEntriesAndKeepsGoodOnes(t *testing.T) {
t.Parallel()
nets, rejected := parseOperatorAllowedCIDRs("198.18.0.0/15, not-a-cidr, 169.254.0.0/16, 10.0.0.0/8")
if len(nets) != 2 {
t.Fatalf("got %d accepted nets, want 2 (198.18.0.0/15 and 10.0.0.0/8)", len(nets))
}
if len(rejected) != 2 {
t.Fatalf("got %d rejections, want 2; rejected=%v", len(rejected), rejected)
}
joined := strings.Join(rejected, " | ")
if !strings.Contains(joined, "not a CIDR") {
t.Fatalf("malformed entry not reported: %v", rejected)
}
if !strings.Contains(joined, "169.254.0.0/16") {
t.Fatalf("protected entry not reported: %v", rejected)
}
}
// isBlocked is the single gate both the pre-flight check and the dial-time
// re-check go through, so the allowlist has to take effect inside it.
func TestIsBlocked_HonorsOperatorAllowlist(t *testing.T) {
saved := operatorAllowedCIDRs
t.Cleanup(func() { operatorAllowedCIDRs = saved })
fakeIP := net.ParseIP("198.18.0.236")
metadata := net.ParseIP("169.254.169.254")
privateIP := net.ParseIP("10.1.2.3")
if !isBlocked(fakeIP) {
t.Fatal("198.18.0.236 should be blocked with no allowlist configured")
}
nets, _ := parseOperatorAllowedCIDRs("198.18.0.0/15")
operatorAllowedCIDRs = nets
if isBlocked(fakeIP) {
t.Fatal("198.18.0.236 should be permitted once its range is allowlisted")
}
if !isBlocked(metadata) {
t.Fatal("cloud metadata must stay blocked regardless of the allowlist")
}
if !isBlocked(privateIP) {
t.Fatal("RFC 1918 must stay blocked when only 198.18.0.0/15 is allowlisted")
}
}
// Validate is what web_fetch calls; it must agree with isBlocked so a URL that
// passes validation can actually be dialed.
func TestValidate_AllowsAllowlistedRangeEndToEnd(t *testing.T) {
saved := operatorAllowedCIDRs
t.Cleanup(func() { operatorAllowedCIDRs = saved })
nets, _ := parseOperatorAllowedCIDRs("198.18.0.0/15")
operatorAllowedCIDRs = nets
// Resolution is what makes this range interesting, so drive isBlocked with
// a literal-IP URL to keep the test off the network.
if _, _, err := validate("https://198.18.0.236/", false, nil); err != nil {
t.Fatalf("allowlisted address rejected by validate: %v", err)
}
if _, _, err := validate("https://169.254.169.254/", false, nil); err == nil {
t.Fatal("cloud metadata passed validate while an allowlist was configured")
}
}
func TestOperatorAllowlistStatus_ReportsConfiguredRanges(t *testing.T) {
savedNets, savedRejected := operatorAllowedCIDRs, operatorAllowlistRejected
t.Cleanup(func() {
operatorAllowedCIDRs, operatorAllowlistRejected = savedNets, savedRejected
})
operatorAllowedCIDRs, operatorAllowlistRejected = parseOperatorAllowedCIDRs("198.18.0.0/15, 169.254.0.0/16")
allowed, rejected := OperatorAllowlistStatus()
if len(allowed) != 1 || allowed[0] != "198.18.0.0/15" {
t.Fatalf("allowed = %v, want [198.18.0.0/15]", allowed)
}
if len(rejected) != 1 {
t.Fatalf("rejected = %v, want one entry", rejected)
}
}