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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user