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:
@@ -61,6 +61,18 @@ fmt.Println(fetchguard.Defaults())
|
|||||||
// 10485760 10s
|
// 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.
|
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
|
## Responses and limits
|
||||||
|
|||||||
@@ -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`.
|
- `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.
|
- 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.
|
- 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.
|
- 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.
|
- 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`).
|
- 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.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.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.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.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.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. |
|
| `fetchguard.Defaults` | Framework fallback limits: 10 MiB and 10 seconds. |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -94,3 +95,13 @@ func ExampleClient_PostJSON() {
|
|||||||
// Output:
|
// Output:
|
||||||
// 201 {"id":42}
|
// 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")
|
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/
|
// isReservedOrPrivate classifies addr against the PHP private/loopback/
|
||||||
// reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition
|
// reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition
|
||||||
// formats.
|
// 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