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.
This commit is contained in:
Jakub Zych
2026-10-03 22:49:19 +02:00
parent f519261da6
commit 7c2c43359f
5 changed files with 55 additions and 0 deletions

View File

@@ -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 <token>`. |
| `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. |

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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")
}
}