Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
261 lines
8.0 KiB
Go
261 lines
8.0 KiB
Go
package forwarded
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseTrusted(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
in string
|
|
want []string
|
|
wantErr string
|
|
}{
|
|
{in: "", want: nil},
|
|
{in: " ", want: nil},
|
|
{in: "10.60.1.33", want: []string{"10.60.1.33/32"}},
|
|
{in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}},
|
|
{in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}},
|
|
{in: "traefik", wantErr: "invalid IP address"},
|
|
{in: "10.60.1.33:443", wantErr: "invalid IP address"},
|
|
{in: "10.60.1.0/33", wantErr: "invalid CIDR"},
|
|
{in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"},
|
|
{in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"},
|
|
{in: "10.60.1.33,", wantErr: "empty entry"},
|
|
{in: "fe80::1%eth0", wantErr: "zone"},
|
|
{in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"},
|
|
{in: "0.0.0.0/0", wantErr: "every peer"},
|
|
{in: "::/0", wantErr: "every peer"},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := ParseTrusted(tt.in)
|
|
if tt.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("ParseTrusted(%q) error = %v", tt.in, err)
|
|
continue
|
|
}
|
|
var gotStr []string
|
|
for _, p := range got {
|
|
gotStr = append(gotStr, p.String())
|
|
}
|
|
if !slices.Equal(gotStr, tt.want) {
|
|
t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrustedContainsMappedIPv4(t *testing.T) {
|
|
t.Parallel()
|
|
trusted := mustTrusted(t, "10.60.1.33")
|
|
if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) {
|
|
t.Error("IPv4-mapped form of a trusted address is not trusted")
|
|
}
|
|
if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) {
|
|
t.Error("an empty set trusts a peer")
|
|
}
|
|
}
|
|
|
|
func mustTrusted(t *testing.T, s string) Trusted {
|
|
t.Helper()
|
|
tr, err := ParseTrusted(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
func TestResolve(t *testing.T) {
|
|
t.Parallel()
|
|
// 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier.
|
|
trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24")
|
|
tests := []struct {
|
|
name string
|
|
peer string
|
|
tls bool
|
|
xff []string
|
|
xfp []string
|
|
wantIP string
|
|
wantScheme string
|
|
}{
|
|
{
|
|
name: "untrusted peer, no headers",
|
|
peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "untrusted peer over TLS",
|
|
peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "untrusted peer spoofing both headers is ignored",
|
|
peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
|
wantIP: "198.51.100.9", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "untrusted peer's malformed headers are ignored, not rejected",
|
|
peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"},
|
|
wantIP: "198.51.100.9", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto",
|
|
peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
|
wantIP: "203.0.113.7", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "trusted peer seen as IPv4-mapped IPv6",
|
|
peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
|
|
wantIP: "203.0.113.7", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "trusted peer without headers is itself the client",
|
|
peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "rightmost untrusted address wins over a client-supplied prefix",
|
|
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"},
|
|
wantIP: "203.0.113.7", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "trusted proxies in the chain are skipped",
|
|
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"},
|
|
wantIP: "203.0.113.7", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "several header lines form one chain",
|
|
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"},
|
|
wantIP: "203.0.113.7", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "garbage left of the client is not examined",
|
|
peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"},
|
|
wantIP: "203.0.113.7", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "all trusted: leftmost is the client",
|
|
peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"},
|
|
wantIP: "10.60.2.5", wantScheme: "http",
|
|
},
|
|
{
|
|
name: "IPv6 client",
|
|
peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"},
|
|
wantIP: "2001:db8::7", wantScheme: "https",
|
|
},
|
|
{
|
|
name: "trusted peer may downgrade to http",
|
|
peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"},
|
|
wantIP: "10.60.1.33", wantScheme: "http",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
r := request(tt.peer, tt.xff, tt.xfp)
|
|
if tt.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
o, err := Resolve(r, trusted)
|
|
if err != nil {
|
|
t.Fatalf("Resolve error = %v", err)
|
|
}
|
|
if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme {
|
|
t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) {
|
|
t.Parallel()
|
|
trusted := mustTrusted(t, "10.60.1.33")
|
|
for _, tt := range []struct {
|
|
name string
|
|
xff, xfp []string
|
|
}{
|
|
{"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil},
|
|
{"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil},
|
|
{"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil},
|
|
{"X-Forwarded-For empty", []string{""}, nil},
|
|
{"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}},
|
|
{"X-Forwarded-Proto list", nil, []string{"https, http"}},
|
|
{"X-Forwarded-Proto repeated", nil, []string{"https", "https"}},
|
|
} {
|
|
_, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted)
|
|
if !errors.Is(err, ErrInvalidHeader) {
|
|
t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveBadPeerAddress(t *testing.T) {
|
|
t.Parallel()
|
|
r := request("@", nil, nil)
|
|
_, err := Resolve(r, nil)
|
|
if err == nil || errors.Is(err, ErrInvalidHeader) {
|
|
t.Errorf("Resolve error = %v, want a peer address error", err)
|
|
}
|
|
}
|
|
|
|
func request(peer string, xff, xfp []string) *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = peer
|
|
for _, v := range xff {
|
|
r.Header.Add(HeaderFor, v)
|
|
}
|
|
for _, v := range xfp {
|
|
r.Header.Add(HeaderProto, v)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func TestContextHelpers(t *testing.T) {
|
|
t.Parallel()
|
|
if _, ok := ClientIP(context.Background()); ok {
|
|
t.Error("ClientIP ok on an unresolved context")
|
|
}
|
|
if _, ok := Scheme(context.Background()); ok {
|
|
t.Error("Scheme ok on an unresolved context")
|
|
}
|
|
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
|
if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" {
|
|
t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok)
|
|
}
|
|
if s, ok := Scheme(ctx); !ok || s != "https" {
|
|
t.Errorf("Scheme = %q %v, want https true", s, ok)
|
|
}
|
|
}
|
|
|
|
func TestLogHandler(t *testing.T) {
|
|
t.Parallel()
|
|
var buf bytes.Buffer
|
|
logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test")
|
|
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
|
|
|
|
logger.InfoContext(ctx, "resolved")
|
|
logger.InfoContext(context.Background(), "unresolved")
|
|
|
|
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String())
|
|
}
|
|
if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") {
|
|
t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0])
|
|
}
|
|
if strings.Contains(lines[1], "client_ip") {
|
|
t.Errorf("unresolved line = %q, want no client_ip", lines[1])
|
|
}
|
|
}
|