Feature 03 task 5: Trusted proxy handling for forwarded headers (#23)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// Package forwarded resolves where a request really came from. The service
|
||||
// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy;
|
||||
// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only
|
||||
// when the peer is a trusted proxy. From anyone else they are ignored, so a
|
||||
// client cannot spoof its address or scheme.
|
||||
package forwarded
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Header names honoured from trusted proxies.
|
||||
const (
|
||||
HeaderFor = "X-Forwarded-For"
|
||||
HeaderProto = "X-Forwarded-Proto"
|
||||
)
|
||||
|
||||
// ErrInvalidHeader marks a malformed forwarded header from a trusted peer.
|
||||
var ErrInvalidHeader = errors.New("invalid forwarded header")
|
||||
|
||||
// Trusted is the set of proxy addresses whose forwarded headers are honoured.
|
||||
// The zero value trusts no peer.
|
||||
type Trusted []netip.Prefix
|
||||
|
||||
// ParseTrusted parses a comma-separated list of IP addresses and CIDR
|
||||
// prefixes. A blank list trusts no peer. Each entry must be a plain address
|
||||
// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and
|
||||
// zero-length prefixes, which would trust every peer, are rejected.
|
||||
func ParseTrusted(s string) (Trusted, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var t Trusted
|
||||
for _, entry := range strings.Split(s, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
return nil, fmt.Errorf("empty entry in comma-separated list %q", s)
|
||||
}
|
||||
p, err := parseEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t = append(t, p)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parseEntry(entry string) (netip.Prefix, error) {
|
||||
var p netip.Prefix
|
||||
if strings.Contains(entry, "/") {
|
||||
var err error
|
||||
p, err = netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry)
|
||||
}
|
||||
if p != p.Masked() {
|
||||
return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked())
|
||||
}
|
||||
} else {
|
||||
a, err := netip.ParseAddr(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry)
|
||||
}
|
||||
if a.Zone() != "" {
|
||||
return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
switch {
|
||||
case p.Addr().Is4In6():
|
||||
return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry)
|
||||
case p.Bits() == 0:
|
||||
return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6
|
||||
// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4
|
||||
// form.
|
||||
func (t Trusted) Contains(a netip.Addr) bool {
|
||||
a = normalize(a)
|
||||
for _, p := range t {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") }
|
||||
|
||||
// Origin is where a request came from.
|
||||
type Origin struct {
|
||||
// ClientIP is the client's address.
|
||||
ClientIP netip.Addr
|
||||
// Scheme is "https" or "http": the scheme the client used.
|
||||
Scheme string
|
||||
}
|
||||
|
||||
// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in
|
||||
// trusted, both forwarded headers are ignored: the client is the peer and the
|
||||
// scheme is that of the connection.
|
||||
//
|
||||
// When the peer is trusted:
|
||||
// - X-Forwarded-For entries (across all its header lines, in order) are
|
||||
// walked right to left, skipping trusted proxy addresses; the first
|
||||
// untrusted address is the client. Entries left of it are client-supplied
|
||||
// and not examined. If every entry is trusted the leftmost is the client;
|
||||
// with no X-Forwarded-For the peer is the client.
|
||||
// - X-Forwarded-Proto, if present, must be a single "http" or "https" and
|
||||
// is the scheme; without it the connection's scheme is used.
|
||||
//
|
||||
// A malformed header from a trusted peer is an error wrapping
|
||||
// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port.
|
||||
func Resolve(r *http.Request, trusted Trusted) (Origin, error) {
|
||||
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr)
|
||||
}
|
||||
o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"}
|
||||
if r.TLS != nil {
|
||||
o.Scheme = "https"
|
||||
}
|
||||
if !trusted.Contains(o.ClientIP) {
|
||||
return o, nil
|
||||
}
|
||||
if values := r.Header.Values(HeaderFor); len(values) > 0 {
|
||||
o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted)
|
||||
if err != nil {
|
||||
return Origin{}, err
|
||||
}
|
||||
}
|
||||
switch values := r.Header.Values(HeaderProto); len(values) {
|
||||
case 0:
|
||||
case 1:
|
||||
switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme {
|
||||
case "http", "https":
|
||||
o.Scheme = scheme
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0])
|
||||
}
|
||||
default:
|
||||
return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// clientFromChain returns the rightmost untrusted address in chain, or the
|
||||
// leftmost address when all are trusted.
|
||||
func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) {
|
||||
var client netip.Addr
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(chain[i]))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i]))
|
||||
}
|
||||
client = normalize(a)
|
||||
if !trusted.Contains(client) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// NewContext returns ctx carrying o.
|
||||
func NewContext(ctx context.Context, o Origin) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, o)
|
||||
}
|
||||
|
||||
// FromContext returns the Origin ctx carries; ok is false when the request
|
||||
// was not resolved.
|
||||
func FromContext(ctx context.Context) (o Origin, ok bool) {
|
||||
o, ok = ctx.Value(contextKey{}).(Origin)
|
||||
return o, ok
|
||||
}
|
||||
|
||||
// ClientIP returns the resolved client address ctx carries.
|
||||
func ClientIP(ctx context.Context) (netip.Addr, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.ClientIP, ok
|
||||
}
|
||||
|
||||
// Scheme returns the resolved request scheme ctx carries.
|
||||
func Scheme(ctx context.Context) (string, bool) {
|
||||
o, ok := FromContext(ctx)
|
||||
return o.Scheme, ok
|
||||
}
|
||||
|
||||
// LogAttr is the log attribute key for the resolved client address.
|
||||
const LogAttr = "client_ip"
|
||||
|
||||
// LogHandler wraps h so every record logged with a resolved request's
|
||||
// context carries the client address as LogAttr.
|
||||
func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} }
|
||||
|
||||
type logHandler struct{ slog.Handler }
|
||||
|
||||
func (h logHandler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
if ip, ok := ClientIP(ctx); ok {
|
||||
rec = rec.Clone()
|
||||
rec.AddAttrs(slog.String(LogAttr, ip.String()))
|
||||
}
|
||||
return h.Handler.Handle(ctx, rec)
|
||||
}
|
||||
|
||||
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return logHandler{h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h logHandler) WithGroup(name string) slog.Handler {
|
||||
return logHandler{h.Handler.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user