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.
542 lines
15 KiB
Go
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")
|
|
}
|
|
}
|