From 7c2c43359fd460384848bb9cd9916906d2ef3cbc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 3 Oct 2026 22:49:19 +0200 Subject: [PATCH] feat(14-04): fetchguard.IsPrivateAddr exposes the dial guard's address classification The golem SSRF guard checks a URL's resolved addresses before it connects, as PHP's SSRFGuard does, with the same table the dial guard uses. --- docs/services/outbound-http.md | 12 ++++++++++++ modules/fetchguard/README.md | 2 ++ modules/fetchguard/example_test.go | 11 +++++++++++ modules/fetchguard/ip.go | 10 ++++++++++ modules/fetchguard/ip_test.go | 20 ++++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/docs/services/outbound-http.md b/docs/services/outbound-http.md index 025eeee..2538063 100644 --- a/docs/services/outbound-http.md +++ b/docs/services/outbound-http.md @@ -61,6 +61,18 @@ fmt.Println(fetchguard.Defaults()) // 10485760 10s ``` +Code that has to judge a resolved address itself, before it connects, uses the same classification through `fetchguard.IsPrivateAddr`: + +```go src=modules/fetchguard/example_test.go#ExampleIsPrivateAddr +for _, ip := range []string{"8.8.8.8", "10.0.0.7", "::ffff:127.0.0.1"} { + fmt.Println(ip, fetchguard.IsPrivateAddr(netip.MustParseAddr(ip))) +} +// Output: +// 8.8.8.8 false +// 10.0.0.7 true +// ::ffff:127.0.0.1 true +``` + A failure is always a `fetchguard.Error` with one `fetchguard.Reason` from a closed set, so a handler can map it to a stable API error code. A host outside the allow list is reported as `invalid_url`, as the example shows. ## Responses and limits diff --git a/modules/fetchguard/README.md b/modules/fetchguard/README.md index fb3d012..b87b2c0 100644 --- a/modules/fetchguard/README.md +++ b/modules/fetchguard/README.md @@ -18,6 +18,7 @@ Guarded outbound HTTP client and fetcher that blocks private and reserved addres - `fetchguard.Client` sends any method: `fetchguard.Client.Do` and `fetchguard.Client.Send` take an `http.Request`, and `fetchguard.Client.Get`, `fetchguard.Client.PostJSON`, `fetchguard.Client.PutJSON` and `fetchguard.Client.PostMultipart` cover the common shapes. `fetchguard.Bearer` builds an Authorization value. Response status and headers come back unjudged in `fetchguard.Result`. - Code-only transport seam: `fetchguard.WithTransport` routes a context's requests to a test `http.RoundTripper`. No policy field, config key, environment variable or header can set it. - The private and reserved address check runs on the resolved IP at dial time, so DNS answers that point inside the network are refused (`fetchguard.ReasonPrivateIP`); environment proxies are ignored so the check sees the real target. +- `fetchguard.IsPrivateAddr` exposes the same address classification, for callers that check a resolved address before they connect (an allowlist guard that also refuses hosts resolving into the network, for example). - Redirects are never followed, in any mode: a 3xx response is returned as a successful `fetchguard.Result`, and a caller that wants to follow the Location header sends a new request, which re-runs the guard. - The response body is capped at the policy's byte limit (`fetchguard.ReasonTooLarge` when exceeded), with a per-call timeout. Request bodies are not capped; callers bound their own inputs. - Limits left at zero in the policy fall back to config keys, then to framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`, `fetchguard.DefaultsFromConfig`). @@ -98,6 +99,7 @@ res, err = vendor.PostMultipart(ctx, "https://api.example.com/v1/files", header, | `fetchguard.FormFile` | One multipart file part (`Field`, `Filename`, `ContentType`, `Body`); the content type defaults to `application/octet-stream`. | | `fetchguard.Bearer` | Returns the Authorization value `Bearer `. | | `fetchguard.WithTransport` | Returns a context whose client requests go to the given `http.RoundTripper`; the test and parity-replay seam, settable only from code. | +| `fetchguard.IsPrivateAddr` | Reports whether an address is private, loopback, link-local, reserved or otherwise not public: the classification the dial guard applies. | | `fetchguard.Error` | Failure carrying a `fetchguard.Reason` and the underlying error for logging. | | `fetchguard.Reason` | Closed set of failure reasons (`invalid_url`, `scheme`, `unresolvable`, `private_ip`, `network_error`, `too_large`). | | `fetchguard.Defaults` | Framework fallback limits: 10 MiB and 10 seconds. | diff --git a/modules/fetchguard/example_test.go b/modules/fetchguard/example_test.go index 6778471..3f0e141 100644 --- a/modules/fetchguard/example_test.go +++ b/modules/fetchguard/example_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/netip" "strings" "time" @@ -94,3 +95,13 @@ func ExampleClient_PostJSON() { // Output: // 201 {"id":42} } + +func ExampleIsPrivateAddr() { + for _, ip := range []string{"8.8.8.8", "10.0.0.7", "::ffff:127.0.0.1"} { + fmt.Println(ip, fetchguard.IsPrivateAddr(netip.MustParseAddr(ip))) + } + // Output: + // 8.8.8.8 false + // 10.0.0.7 true + // ::ffff:127.0.0.1 true +} diff --git a/modules/fetchguard/ip.go b/modules/fetchguard/ip.go index cc90e43..5a79a9c 100644 --- a/modules/fetchguard/ip.go +++ b/modules/fetchguard/ip.go @@ -32,6 +32,16 @@ var ( sixToFourPrefix = netip.MustParsePrefix("2002::/16") ) +// IsPrivateAddr reports whether addr is not a public address: private, +// loopback, link-local, carrier-grade NAT, documentation, multicast, +// unspecified or another reserved IPv4 or IPv6 range, including IPv4 +// embedded in NAT64 and 6to4 addresses. An invalid address counts as +// private. It is the classification the dial guard applies, for callers +// that check a resolved address before they connect. +func IsPrivateAddr(addr netip.Addr) bool { + return isReservedOrPrivate(addr) +} + // isReservedOrPrivate classifies addr against the PHP private/loopback/ // reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition // formats. diff --git a/modules/fetchguard/ip_test.go b/modules/fetchguard/ip_test.go index d77e555..54ad270 100644 --- a/modules/fetchguard/ip_test.go +++ b/modules/fetchguard/ip_test.go @@ -176,3 +176,23 @@ func TestIsReservedOrPrivateIgnoresZone(t *testing.T) { } } } + +func TestIsPrivateAddr(t *testing.T) { + for ip, want := range map[string]bool{ + "127.0.0.1": true, + "169.254.169.254": true, + "10.1.2.3": true, + "::1": true, + "fd12:3456:789a::1": true, + "64:ff9b::a00:1": true, + "8.8.8.8": false, + "2001:4860:4860::8888": false, + } { + if got := IsPrivateAddr(netip.MustParseAddr(ip)); got != want { + t.Errorf("IsPrivateAddr(%s) = %v, want %v", ip, got, want) + } + } + if !IsPrivateAddr(netip.Addr{}) { + t.Error("an invalid address must count as private") + } +}