package netutil
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"slices"
"testing"
"time"
)
type mockResolver struct {
ips map[string][]netip.Addr
err map[string]error
}
func (m *mockResolver) LookupIP(_ context.Context, host string) ([]netip.Addr, error) {
if err, ok := m.err[host]; ok {
return nil, err
}
if ips, ok := m.ips[host]; ok {
return ips, nil
}
return nil, errors.New("nxdomain")
}
type mockDialer struct {
dialedAddr string
attempts []string
failOn map[string]error
}
func (m *mockDialer) DialContext(_ context.Context, _, address string) (net.Conn, error) {
m.dialedAddr = address
m.attempts = append(m.attempts, address)
if err, ok := m.failOn[address]; ok {
return nil, err
}
server, client := net.Pipe()
go func() {
_ = server.Close()
}()
return client, nil
}
func TestProxyRejectsNonConnect(t *testing.T) {
p := NewSafeConnectProxy(nil, nil, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
resp, err := http.Get("http://" + addr + "/foo")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
}
}
func TestProxyRejectsBadPort(t *testing.T) {
p := NewSafeConnectProxy(nil, nil, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
fmt.Fprintf(conn, "CONNECT github.com:80 HTTP/1.1\r\nHost: github.com:80\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestProxyRejectsDirectPrivateIP(t *testing.T) {
p := NewSafeConnectProxy(nil, nil, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
for _, target := range []string{"127.0.0.1:443", "10.0.0.1:443", "192.168.1.5:443", "169.254.169.254:443", "[::1]:443", "[2002:7f00:1::]:443"} {
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
conn.Close()
t.Fatal(err)
}
conn.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("target %s returned status %d, want 403 Forbidden", target, resp.StatusCode)
}
}
}
func TestProxyRejectsMixedDNSAnswer(t *testing.T) {
res := &mockResolver{
ips: map[string][]netip.Addr{
"mixed.example.com": {
netip.MustParseAddr("140.82.112.4"), // Safe public IP
netip.MustParseAddr("127.0.0.1"),
},
},
}
p := NewSafeConnectProxy(res, nil, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
fmt.Fprintf(conn, "CONNECT mixed.example.com:443 HTTP/1.1\r\nHost: mixed.example.com:443\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestProxyPermitsSafeDestinationAndDialsPinnedIP(t *testing.T) {
safeIP := netip.MustParseAddr("140.82.112.4")
res := &mockResolver{
ips: map[string][]netip.Addr{
"github.com": {safeIP},
},
}
dialer := &mockDialer{}
p := NewSafeConnectProxy(res, dialer, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
fmt.Fprintf(conn, "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
expectedDial := safeIP.String() + ":443"
if dialer.dialedAddr != expectedDial {
t.Fatalf("dialed %q, want %q", dialer.dialedAddr, expectedDial)
}
}
func TestProxyReachesLoopbackOnlyWhenAllowed(t *testing.T) {
loopback := netip.MustParseAddr("127.0.0.1")
res := &mockResolver{
ips: map[string][]netip.Addr{
"github.tngl.boltless.dev": {loopback},
},
}
for _, allow := range []bool{false, true} {
dialer := &mockDialer{}
p := NewSafeConnectProxy(res, dialer, allow)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(conn, "CONNECT github.tngl.boltless.dev:443 HTTP/1.1\r\nHost: github.tngl.boltless.dev:443\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
conn.Close()
p.Close()
if err != nil {
t.Fatal(err)
}
want := http.StatusForbidden
if allow {
want = http.StatusOK
}
if resp.StatusCode != want {
t.Fatalf("allowPrivate=%v: status = %d, want %d", allow, resp.StatusCode, want)
}
if allow != (dialer.dialedAddr == "127.0.0.1:443") {
t.Fatalf("allowPrivate=%v: dialed %q", allow, dialer.dialedAddr)
}
}
}
func TestProxyTriesEveryResolvedAddress(t *testing.T) {
unreachable := netip.MustParseAddr("140.82.112.4")
reachable := netip.MustParseAddr("140.82.113.4")
res := &mockResolver{
ips: map[string][]netip.Addr{"github.com": {unreachable, reachable}},
}
dialer := &mockDialer{
failOn: map[string]error{unreachable.String() + ":443": errors.New("network is unreachable")},
}
p := NewSafeConnectProxy(res, dialer, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
fmt.Fprintf(conn, "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d: a v4-only network is not a refusal", resp.StatusCode, http.StatusOK)
}
want := []string{unreachable.String() + ":443", reachable.String() + ":443"}
if !slices.Equal(dialer.attempts, want) {
t.Fatalf("dialled %v, want %v", dialer.attempts, want)
}
}
func TestProxyReleasesATunnelThatStopsTalking(t *testing.T) {
old := relayDrainGrace
relayDrainGrace = 100 * time.Millisecond
defer func() { relayDrainGrace = old }()
safeIP := netip.MustParseAddr("140.82.112.4")
res := &mockResolver{ips: map[string][]netip.Addr{"github.com": {safeIP}}}
serverConn, peerConn := net.Pipe()
defer peerConn.Close()
dialer := &pipeDialer{conn: serverConn}
p := NewSafeConnectProxy(res, dialer, false)
addr, err := p.Start()
if err != nil {
t.Fatal(err)
}
defer p.Close()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(conn, "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\n\r\n")
if _, err := http.ReadResponse(bufio.NewReader(conn), nil); err != nil {
t.Fatal(err)
}
conn.Close()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
p.mu.Lock()
open := len(p.active)
p.mu.Unlock()
if open == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("the tunnel outlived the relay: a half-closed pair is never reaped")
}
type pipeDialer struct{ conn net.Conn }
func (d *pipeDialer) DialContext(context.Context, string, string) (net.Conn, error) {
return d.conn, nil
}