mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
// 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")
|
|
}
|
|
}
|