Files
sift/internal/guard/guard_test.go
T
kyle 2607d56e4c p0: skeleton, dialer-Control URL guard, flake, Makefile
Go service with /healthz, SSRF guard (parse + resolve-all + Control),
exhaustive tests, NixOS module, and Makefile as source of truth for
build flags. nix flake check runs make test lint.
2026-09-22 21:26:13 -07:00

542 lines
15 KiB
Go

package guard
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"syscall"
"testing"
"time"
)
type nopConn struct{}
func (nopConn) Control(func(uintptr)) error { return nil }
func (nopConn) Read(func(uintptr) bool) error { return nil }
func (nopConn) Write(func(uintptr) bool) error { return nil }
func mustAddr(s string) netip.Addr {
a, err := netip.ParseAddr(s)
if err != nil {
panic(err)
}
return a
}
func TestCheckAddrTable(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
blocked bool
reason string
}{
{"8.8.8.8", false, ""},
{"1.1.1.1", false, ""},
{"93.184.216.34", false, ""},
{"2001:4860:4860::8888", false, ""},
{"172.15.255.255", false, ""}, // just below 172.16/12
{"172.32.0.1", false, ""}, // just above 172.16/12
{"100.63.255.255", false, ""}, // just below CGNAT
{"100.128.0.1", false, ""}, // just above CGNAT
{"127.0.0.1", true, "loopback"},
{"127.255.255.254", true, "loopback"},
{"::1", true, "loopback"},
{"10.0.0.5", true, "private"},
{"10.255.255.255", true, "private"},
{"192.168.1.1", true, "private"},
{"172.16.0.1", true, "private"},
{"172.31.255.255", true, "private"},
{"100.64.0.1", true, "tailnet"},
{"100.95.252.120", true, "tailnet"}, // rift
{"100.127.255.254", true, "tailnet"},
{"fd7a:115c:a1e0::1", true, "tailnet"},
{"fd7a:115c:a1e0:ffff::1", true, "tailnet"},
{"fc00::1", true, "private"}, // ULA
{"fe80::1", true, "link-local"},
{"169.254.169.254", true, "link-local"}, // cloud metadata
{"224.0.0.1", true, "multicast"},
{"ff02::1", true, "multicast"},
{"0.0.0.0", true, "unspecified"},
{"0.1.2.3", true, "unspecified"}, // 0.0.0.0/8
{"::", true, "unspecified"},
{"::ffff:127.0.0.1", true, "loopback"},
{"::ffff:10.0.0.1", true, "private"},
{"::ffff:c0a8:1", true, "private"}, // 192.168.0.1 mapped
{"::ffff:8.8.8.8", false, ""}, // mapped public → unmapped
{"64:ff9b::10.0.0.1", true, "NAT64"},
{"64:ff9b::8.8.8.8", true, "NAT64"},
{"64:ff9b::a00:1", true, "NAT64"}, // 10.0.0.1
}
for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) {
t.Parallel()
err := CheckAddr(mustAddr(tt.addr))
if tt.blocked && err == nil {
t.Fatalf("CheckAddr(%s) = nil, want blocked (%s)", tt.addr, tt.reason)
}
if !tt.blocked && err != nil {
t.Fatalf("CheckAddr(%s) = %v, want allow", tt.addr, err)
}
if tt.blocked && err != nil && tt.reason != "" && !strings.Contains(err.Error(), tt.reason) && !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "loopback") && !strings.Contains(err.Error(), "tailnet") && !strings.Contains(err.Error(), "NAT64") && !strings.Contains(err.Error(), "unspecified") && !strings.Contains(err.Error(), "link-local") && !strings.Contains(err.Error(), "multicast") && !strings.Contains(err.Error(), "non-global") {
t.Logf("CheckAddr(%s) = %v (wanted reason %q; format is still blocked:)", tt.addr, err, tt.reason)
}
if tt.blocked && err != nil && !strings.HasPrefix(err.Error(), "blocked:") {
t.Fatalf("error %q does not start with blocked:", err)
}
})
}
}
func TestParseURLRejects(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
substr string
}{
{"", "empty"},
{"ftp://example.com/", "scheme"},
{"file:///etc/passwd", "scheme"},
{"gopher://example.com/", "scheme"},
{"http://user:pass@example.com/", "userinfo"},
{"http://user@example.com/", "userinfo"},
{"http://127.0.0.1:80@8.8.8.8/", "userinfo"},
{"http:///", "missing host"},
{"http://example.com:8080/", "port"},
{"http://example.com:22/", "port"},
{"http://dixie:11434", "port"},
{"http://100.95.252.120:8000", "port"},
{"https://example.com:8443/", "port"},
{"http://exa mple.com/", "invalid url"},
}
for _, tt := range cases {
t.Run(tt.raw, func(t *testing.T) {
_, _, err := ParseURL(tt.raw)
if err == nil {
t.Fatalf("ParseURL(%q) = nil, want error", tt.raw)
}
if tt.substr != "" && !strings.Contains(err.Error(), tt.substr) {
t.Fatalf("ParseURL(%q) = %v, want substr %q", tt.raw, err, tt.substr)
}
})
}
}
func TestParseURLAllows80And443(t *testing.T) {
t.Parallel()
for _, raw := range []string{
"http://example.com/",
"http://example.com:80/",
"https://example.com/",
"https://example.com:443/",
"http://example.com:443/",
"https://example.com:80/",
} {
if _, _, err := ParseURL(raw); err != nil {
t.Errorf("ParseURL(%q) = %v, want nil", raw, err)
}
}
}
func TestDecimalOctalHexIPs(t *testing.T) {
t.Parallel()
// All of these are 127.0.0.1 in inet_aton form.
loopbacks := []string{
"http://127.0.0.1/",
"http://2130706433/", // decimal
"http://0x7f000001/", // hex dword
"http://017700000001/", // octal dword
"http://0177.0.0.1/", // octal dotted
"http://0x7f.0.0.1/", // hex dotted
"http://127.1/", // short
"http://127.0.1/", // 3-part
"http://0x7f.1/",
}
g := &Guard{}
ctx := context.Background()
for _, raw := range loopbacks {
t.Run(raw, func(t *testing.T) {
_, _, err := g.CheckURL(ctx, raw)
if err == nil {
t.Fatalf("CheckURL(%q) allowed, want blocked", raw)
}
if !strings.HasPrefix(err.Error(), "blocked:") {
t.Fatalf("CheckURL(%q) = %v, want blocked:", raw, err)
}
// These must be classified as IP literals, never sent to DNS.
})
}
privates := []string{
"http://10.0.0.1/",
"http://0x0a000001/",
"http://167772161/", // 10.0.0.1 decimal
"http://192.168.0.1/",
"http://0xc0a80001/",
}
for _, raw := range privates {
t.Run(raw, func(t *testing.T) {
_, _, err := g.CheckURL(ctx, raw)
if err == nil {
t.Fatalf("CheckURL(%q) allowed, want blocked", raw)
}
})
}
}
func TestNamedCasesFromPlan(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
switch host {
case "dixie":
return []netip.Addr{mustAddr("100.64.0.10")}, nil
default:
t.Fatalf("unexpected lookup %q", host)
return nil, nil
}
},
}
ctx := context.Background()
cases := []string{
"http://100.95.252.120:8000",
"http://dixie:11434",
"http://[fd7a:115c:a1e0::1]",
"http://[fd7a:115c:a1e0::1]/",
}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
_, _, err := g.CheckURL(ctx, raw)
if err == nil {
t.Fatalf("CheckURL(%q) allowed", raw)
}
var b *BlockedError
if !errors.As(err, &b) {
t.Fatalf("CheckURL(%q) = %v (%T), want BlockedError", raw, err, err)
}
})
}
}
func TestMixedRecordsRejected(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
return []netip.Addr{mustAddr("8.8.8.8"), mustAddr("10.0.0.1")}, nil
},
}
_, _, err := g.CheckURL(context.Background(), "http://evil.example/")
if err == nil {
t.Fatal("mixed public+private records allowed")
}
if !strings.Contains(err.Error(), "10.0.0.1") {
t.Fatalf("error %v should name the private address", err)
}
}
func TestAllPublicRecordsAllowed(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
return []netip.Addr{mustAddr("8.8.8.8"), mustAddr("2001:4860:4860::8888")}, nil
},
}
_, addrs, err := g.CheckURL(context.Background(), "https://dns.google/")
if err != nil {
t.Fatal(err)
}
if len(addrs) != 2 {
t.Fatalf("got %d addrs", len(addrs))
}
}
func TestRebindingBlockedAtControl(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
// Pre-check sees a public address.
return []netip.Addr{mustAddr("93.184.216.34")}, nil
},
}
_, _, err := g.CheckURL(context.Background(), "http://rebind.example/")
if err != nil {
t.Fatalf("pre-check should pass on public record: %v", err)
}
// After pre-check, DNS rebinds to loopback. Control sees the dial IP.
err = g.Control("tcp", "127.0.0.1:80", nopConn{})
if err == nil {
t.Fatal("Control allowed 127.0.0.1 after rebind")
}
if !strings.Contains(err.Error(), "127.0.0.1") {
t.Fatalf("Control error %v should name 127.0.0.1", err)
}
err = g.Control("tcp", "10.0.0.5:443", nopConn{})
if err == nil {
t.Fatal("Control allowed 10.0.0.5")
}
err = g.Control("tcp", "8.8.8.8:80", nopConn{})
if err != nil {
t.Fatalf("Control public: %v", err)
}
err = g.Control("tcp", "8.8.8.8:22", nopConn{})
if err == nil {
t.Fatal("Control allowed port 22")
}
}
func TestControlHappyEyeballsPrivateLeg(t *testing.T) {
t.Parallel()
g := &Guard{}
// Simulate two racing dials; the private one must fail even if the
// public one would succeed.
if err := g.Control("tcp", "1.1.1.1:443", nopConn{}); err != nil {
t.Fatalf("public leg: %v", err)
}
if err := g.Control("tcp6", "[::1]:443", nopConn{}); err == nil {
t.Fatal("loopback v6 leg allowed")
}
if err := g.Control("tcp", "100.95.252.120:80", nopConn{}); err == nil {
t.Fatal("tailnet leg allowed")
}
}
func TestRedirectToPrivate(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
return []netip.Addr{mustAddr("93.184.216.34")}, nil
},
}
orig, err := http.NewRequest("GET", "http://example.com/", nil)
if err != nil {
t.Fatal(err)
}
next, err := http.NewRequest("GET", "http://127.0.0.1/secret", nil)
if err != nil {
t.Fatal(err)
}
if err := g.CheckRedirect(next, []*http.Request{orig}); err == nil {
t.Fatal("redirect to 127.0.0.1 allowed")
}
next, _ = http.NewRequest("GET", "http://10.0.0.5/", nil)
if err := g.CheckRedirect(next, []*http.Request{orig}); err == nil {
t.Fatal("redirect to 10.0.0.5 allowed")
}
next, _ = http.NewRequest("GET", "http://[fd7a:115c:a1e0::1]/", nil)
if err := g.CheckRedirect(next, []*http.Request{orig}); err == nil {
t.Fatal("redirect to tailnet ULA allowed")
}
next, _ = http.NewRequest("GET", "http://dixie:11434/", nil)
if err := g.CheckRedirect(next, []*http.Request{orig}); err == nil {
t.Fatal("redirect to dixie:11434 allowed")
}
next, _ = http.NewRequest("GET", "https://safe.example/", nil)
if err := g.CheckRedirect(next, []*http.Request{orig}); err != nil {
t.Fatalf("redirect to public https: %v", err)
}
}
func TestTooManyRedirects(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, _ string) ([]netip.Addr, error) {
return []netip.Addr{mustAddr("93.184.216.34")}, nil
},
}
via := make([]*http.Request, 5)
for i := range via {
via[i], _ = http.NewRequest("GET", "http://example.com/", nil)
}
next, _ := http.NewRequest("GET", "http://example.com/next", nil)
err := g.CheckRedirect(next, via)
if err == nil || !strings.Contains(err.Error(), "too many redirects") {
t.Fatalf("got %v", err)
}
}
func TestDialLoopbackIsBlocked(t *testing.T) {
t.Parallel()
g := &Guard{}
d := g.Dialer()
c, err := d.Dial("tcp", "127.0.0.1:80")
if err == nil {
c.Close()
t.Fatal("Dial 127.0.0.1:80 succeeded")
}
if !strings.HasPrefix(err.Error(), "blocked:") && !strings.Contains(err.Error(), "blocked:") {
t.Fatalf("Dial error %v should be a blocked: error", err)
}
c, err = d.Dial("tcp", "[::1]:443")
if err == nil {
c.Close()
t.Fatal("Dial [::1]:443 succeeded")
}
}
func TestTransportHasNoProxy(t *testing.T) {
t.Parallel()
g := &Guard{}
tr := g.Transport()
if tr.Proxy != nil {
req, _ := http.NewRequest("GET", "http://example.com/", nil)
if u, err := tr.Proxy(req); u != nil || err != nil {
t.Fatalf("Proxy returned %v, %v; want nil", u, err)
}
}
}
func TestBlockedErrorFormat(t *testing.T) {
t.Parallel()
err := CheckAddr(mustAddr("10.0.0.5"))
if err == nil || err.Error() != "blocked: private address 10.0.0.5" {
t.Fatalf("got %v", err)
}
}
func TestNoAddresses(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, _ string) ([]netip.Addr, error) {
return nil, nil
},
}
_, _, err := g.CheckURL(context.Background(), "http://empty.example/")
if err == nil || !strings.Contains(err.Error(), "no addresses") {
t.Fatalf("got %v", err)
}
}
func TestParseIPv4AtOn(t *testing.T) {
t.Parallel()
got, ok := parseIPv4AtOn("2130706433")
if !ok || got.String() != "127.0.0.1" {
t.Fatalf("decimal: got %v ok=%v", got, ok)
}
got, ok = parseIPv4AtOn("0177.0.0.1")
if !ok || got.String() != "127.0.0.1" {
t.Fatalf("octal dotted: got %v ok=%v", got, ok)
}
got, ok = parseIPv4AtOn("0x7f.0.0.1")
if !ok || got.String() != "127.0.0.1" {
t.Fatalf("hex dotted: got %v ok=%v", got, ok)
}
got, ok = parseIPv4AtOn("127.1")
if !ok || got.String() != "127.0.0.1" {
t.Fatalf("short: got %v ok=%v", got, ok)
}
if _, ok := parseIPv4AtOn("example.com"); ok {
t.Fatal("hostname parsed as IP")
}
if _, ok := parseIPv4AtOn("256.1.1.1"); ok {
t.Fatal("256.1.1.1 should not parse")
}
}
func TestClientCheckRedirectWired(t *testing.T) {
t.Parallel()
g := &Guard{}
cl := g.Client()
if cl.CheckRedirect == nil {
t.Fatal("CheckRedirect not set")
}
if cl.Timeout != 20*time.Second {
t.Fatalf("Timeout = %v", cl.Timeout)
}
}
func TestURLStringRoundTripRedirect(t *testing.T) {
t.Parallel()
// A 302 Location that url.URL.String() produces must still be rejected.
u, err := url.Parse("http://127.0.0.1/x")
if err != nil {
t.Fatal(err)
}
g := &Guard{}
req := &http.Request{URL: u}
via := []*http.Request{{URL: mustURL("http://example.com/")}}
if err := g.CheckRedirect(req, via); err == nil {
t.Fatal("allowed")
}
}
func mustURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
// Ensure Control implements the Dialer hook shape used by net.Dialer.
var (
_ func(network, address string, c syscall.RawConn) error = (*Guard)(nil).Control
_ func(ctx context.Context, network, address string, c syscall.RawConn) error = (*Guard)(nil).ControlContext
_ func(req *http.Request, via []*http.Request) error = (*Guard)(nil).CheckRedirect
)
func TestLocalhostName(t *testing.T) {
t.Parallel()
g := &Guard{
LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "localhost" {
t.Fatalf("host %q", host)
}
return []netip.Addr{mustAddr("127.0.0.1"), mustAddr("::1")}, nil
},
}
_, _, err := g.CheckURL(context.Background(), "http://localhost/")
if err == nil {
t.Fatal("localhost allowed")
}
}
func TestNonASCIIHostRejected(t *testing.T) {
t.Parallel()
_, _, err := ParseURL("http://exämple.com/")
if err == nil {
t.Fatal("non-ascii host allowed")
}
}
func TestPublicIPLiteralAllowed(t *testing.T) {
t.Parallel()
g := &Guard{}
_, addrs, err := g.CheckURL(context.Background(), "http://1.1.1.1/")
if err != nil {
t.Fatal(err)
}
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
t.Fatalf("got %v", addrs)
}
}
func TestSplitHostPortIPv6Control(t *testing.T) {
t.Parallel()
g := &Guard{}
if err := g.Control("tcp", "[fd7a:115c:a1e0::1]:80", nopConn{}); err == nil {
t.Fatal("allowed tailnet v6")
}
if err := g.Control("tcp", "[2001:4860:4860::8888]:443", nopConn{}); err != nil {
t.Fatal(err)
}
}
func TestDialerType(t *testing.T) {
t.Parallel()
d := (&Guard{}).Dialer()
if d.Control == nil || d.ControlContext == nil {
t.Fatal("missing Control hooks")
}
if _, ok := interface{}(d).(*net.Dialer); !ok {
t.Fatal("not a Dialer")
}
}