test: failing tests for blocked_networks config and extended SSRF ranges
Table-driven coverage for the CGNAT, IETF-protocol, benchmark, and NAT64 ranges (IPv4, IPv6, and IPv4-mapped forms), an operator-supplied blocked_networks entry enforced by the dialer, and strict parsing that aborts startup naming the key and the offending value. Model: opus-4-8
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
|
||||||
|
// each CIDR is parsed into the resolved prefixes in order.
|
||||||
|
func TestBlockedNetworksParsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `blocked_networks:
|
||||||
|
- 203.0.113.0/24
|
||||||
|
- 2001:db8::/32
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid blocked_networks should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"203.0.113.0/24", "2001:db8::/32"}
|
||||||
|
if len(c.BlockedNetworks) != len(want) {
|
||||||
|
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range want {
|
||||||
|
if got := c.BlockedNetworks[i].String(); got != w {
|
||||||
|
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
|
||||||
|
// operator list empty; the built-in defaults still apply in the fetcher.
|
||||||
|
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.BlockedNetworks) != 0 {
|
||||||
|
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
|
||||||
|
// startup with an error naming the key and the offending value.
|
||||||
|
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, []abortCase{
|
||||||
|
{
|
||||||
|
name: "not-a-cidr",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- not-a-cidr
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare-address-without-prefix",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 10.0.0.1
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- ""
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-string-entry",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
- 42
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null-value",
|
||||||
|
yaml: signingKeyLine + `blocked_networks:
|
||||||
|
`,
|
||||||
|
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
|
||||||
|
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
|
||||||
|
// forms, alongside public controls that must stay reachable.
|
||||||
|
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cgnat-low", "100.64.0.1", true},
|
||||||
|
{"cgnat-high", "100.127.255.254", true},
|
||||||
|
{"ietf-protocol", "192.0.0.1", true},
|
||||||
|
{"benchmark-low", "198.18.0.1", true},
|
||||||
|
{"benchmark-high", "198.19.255.254", true},
|
||||||
|
{"nat64", "64:ff9b::1", true},
|
||||||
|
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
|
||||||
|
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
|
||||||
|
{"cloud-metadata", "169.254.169.254", true},
|
||||||
|
{"public-v4", "8.8.8.8", false},
|
||||||
|
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
|
||||||
|
{"public-v6", "2001:4860:4860::8888", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportOf returns the *http.Transport backing a fetcher, so a test can
|
||||||
|
// exercise the SSRF-safe dialer New installed with the operator blocklist.
|
||||||
|
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
transport, ok := f.client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
|
||||||
|
// blocked_networks entry is enforced by the dialer, in addition to the
|
||||||
|
// built-in ranges, while an address outside both stays dialable.
|
||||||
|
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
|
||||||
|
// blocking it can only come from the operator-supplied list.
|
||||||
|
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
|
||||||
|
|
||||||
|
transport := transportOf(t, New(cfg))
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"198.51.100.5:80", // operator-supplied range
|
||||||
|
"10.0.0.5:80", // built-in RFC 1918, still enforced
|
||||||
|
"100.64.0.1:80", // built-in CGNAT range
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range blocked {
|
||||||
|
t.Run("blocked/"+addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(context.Background(), "tcp", addr)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("public-not-blocked", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A cancelled context makes the dial fail without touching the
|
||||||
|
// network; the point is only that a public literal outside every
|
||||||
|
// blocked range is not SSRF-blocked.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
|
||||||
|
if errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user