Files

188 lines
5.5 KiB
Go
Raw Permalink Normal View History

// Pass 4 — host HTTP allowlist + IP guard tests.
//
// Locks in the SSRF defenses added in Pass 2 (dot-bounded host suffix
// matching, empty-entry rejection) and Pass 3 (RFC6598 CGN rejection).
package plugin
import (
"context"
"errors"
"net"
"testing"
)
func newTestRegistry(allowlist []string) *Registry {
return &Registry{cfg: Config{HTTPAllowlist: allowlist}}
}
func TestHostAllowedDotBoundary(t *testing.T) {
r := newTestRegistry([]string{"api.example.com", "example.org"})
cases := []struct {
host string
ok bool
}{
{"api.example.com", true},
{"v1.api.example.com", true},
{"example.org", true},
{"sub.example.org", true},
// Sibling-domain attack — must NOT match.
{"evil-api.example.com", false},
{"notexample.com", false},
{"example.com", false}, // not in list
{"evil.com", false},
{"", false},
{"api.example.com.evil.com", false},
}
for _, c := range cases {
got := r.hostAllowed(c.host)
if got != c.ok {
t.Errorf("hostAllowed(%q) = %v, want %v", c.host, got, c.ok)
}
}
}
func TestHostAllowedEmptyEntryRejected(t *testing.T) {
r := newTestRegistry([]string{""})
if r.hostAllowed("anything.com") {
t.Fatal("empty allowlist entry must NOT wildcard-match")
}
if r.hostAllowed("") {
t.Fatal("empty host must not match empty entry")
}
}
func TestHostAllowedCaseInsensitive(t *testing.T) {
r := newTestRegistry([]string{"API.Example.COM"})
if !r.hostAllowed("api.example.com") {
t.Fatal("hostAllowed should be case-insensitive")
}
if !r.hostAllowed("API.example.com") {
t.Fatal("hostAllowed should be case-insensitive")
}
}
func TestHostAllowedTrailingDot(t *testing.T) {
r := newTestRegistry([]string{"api.example.com"})
if !r.hostAllowed("api.example.com.") {
t.Fatal("FQDN trailing dot should match")
}
}
func TestIPAllowedRejectsAllRanges(t *testing.T) {
cases := []string{
"127.0.0.1", // loopback
"127.5.6.7", // loopback range
"10.0.0.1", // RFC1918
"172.16.5.5", // RFC1918
"172.31.255.255", // RFC1918 high
"192.168.1.1", // RFC1918
"169.254.169.254", // AWS metadata / link-local
"100.64.5.5", // RFC6598 CGN
"100.127.255.255", // RFC6598 CGN high
"::1", // IPv6 loopback
"fc00::1", // RFC4193 ULA
"fe80::1", // IPv6 link-local
"0.0.0.0", // unspecified
"::", // IPv6 unspecified
"224.0.0.1", // multicast
"ff00::1", // IPv6 multicast
}
for _, addr := range cases {
ip := net.ParseIP(addr)
if ip == nil {
t.Fatalf("ParseIP(%q) failed", addr)
}
if err := ipAllowed(ip); err == nil {
t.Errorf("ipAllowed(%s) should have returned error", addr)
}
}
}
func TestIPAllowedAcceptsPublic(t *testing.T) {
cases := []string{
"8.8.8.8",
"1.1.1.1",
"203.0.113.5", // RFC5737 documentation but not in any reject set
"2606:4700:4700::1111",
}
for _, addr := range cases {
ip := net.ParseIP(addr)
if ip == nil {
t.Fatalf("ParseIP(%q) failed", addr)
}
if err := ipAllowed(ip); err != nil {
t.Errorf("ipAllowed(%s) should have been allowed, got %v", addr, err)
}
}
}
// TestGuardedDial_FallsBackAcrossVettedIPs locks the W2-6 fix: an allowlisted
// dual-stack/round-robin host whose first record is unreachable must connect
// via the next vetted record instead of hard-failing.
func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) {
origLookup, origDial := lookupIPAddr, dialContext
t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })
lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
return []net.IPAddr{
{IP: net.ParseIP("192.0.2.1")}, // TEST-NET, "down"
{IP: net.ParseIP("192.0.2.2")}, // "reachable"
}, nil
}
var attempts []string
c1, c2 := net.Pipe()
t.Cleanup(func() { _ = c1.Close(); _ = c2.Close() })
dialContext = func(_ context.Context, _ string, addr string) (net.Conn, error) {
attempts = append(attempts, addr)
if addr == "192.0.2.1:443" {
return nil, errors.New("connection refused")
}
return c1, nil
}
conn, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
if err != nil {
t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err)
}
if conn != c1 {
t.Fatal("expected the fallback connection")
}
want := []string{"192.0.2.1:443", "192.0.2.2:443"}
if len(attempts) != 2 || attempts[0] != want[0] || attempts[1] != want[1] {
t.Fatalf("dial attempts = %v, want %v", attempts, want)
}
}
// TestGuardedDial_PrivateRecordRefusesBeforeAnyDial: one private record among
// the resolved set refuses the whole request before a single dial happens.
func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) {
origLookup, origDial := lookupIPAddr, dialContext
t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })
lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
return []net.IPAddr{
{IP: net.ParseIP("192.0.2.1")},
{IP: net.ParseIP("10.0.0.5")}, // poisoned private record
}, nil
}
dialed := false
dialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) {
dialed = true
return nil, errors.New("must not be reached")
}
_, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
if !errors.Is(err, ErrHTTPHostDenied) {
t.Fatalf("want ErrHTTPHostDenied, got %v", err)
}
if dialed {
t.Fatal("no dial may happen when any resolved record is private")
}
}
func TestIPAllowedNilRejected(t *testing.T) {
if err := ipAllowed(nil); err == nil {
t.Fatal("nil IP should be rejected")
}
}