Files
weave/lb/gateway_proxy_test.go
levaliciousandClaude Opus 5.5 5d6b0719b3
images / images (push) Successful in 7m29s
lb: route filters may not change the forwarding headers
The proxy sets Forwarded, X-Forwarded-For/-Host/-Proto and X-Real-IP from
the connection, but applied a route's request header filters after that,
so whoever could write a route to a backend could tell it any client
address (a backend that trusts the gateway's X-Forwarded-For believes it).

An HTTPRoute whose request header filter sets, adds or removes one of
them (names compared case-insensitively) is now not accepted
(UnsupportedValue), like the other filters we do not serve. The proxy
also sets them after the filters, clearing them first: SetXForwarded
appends to an X-Forwarded-For already there.

The proxy test's backend now prints every X-Forwarded-For value; it
printed only the first, which hid an appended one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-14 10:29:35 -04:00

334 lines
14 KiB
Go

package lb
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
// testProxy binds every requested address on loopback and remembers where.
type testProxy struct {
*Proxy
mu sync.Mutex
actual map[string]string
}
func newTestProxy(t *testing.T) *testProxy {
tp := &testProxy{Proxy: NewProxy(), actual: map[string]string{}}
tp.listen = func(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err == nil {
tp.mu.Lock()
tp.actual[addr] = ln.Addr().String()
tp.mu.Unlock()
}
return ln, err
}
t.Cleanup(tp.Close)
return tp
}
func (tp *testProxy) at(port int32) string {
tp.mu.Lock()
defer tp.mu.Unlock()
return tp.actual[net.JoinHostPort("192.0.2.1", strconv.Itoa(int(port)))]
}
// named is a backend that answers with its name and the request it saw.
func named(t *testing.T, name string) (*httptest.Server, Endpoint) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend", name)
n, _ := io.Copy(io.Discard, r.Body)
fmt.Fprintf(w, "%s path=%s host=%s xff=%s proto=%s xhost=%s realip=%s added=%s removed=%s body=%d",
name, r.URL.Path, r.Host, strings.Join(r.Header.Values("X-Forwarded-For"), ","), r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Real-IP"), strings.Join(r.Header.Values("X-Added"), ","),
r.Header.Get("X-Remove-Me"), n)
}))
t.Cleanup(s.Close)
_, port, _ := net.SplitHostPort(s.Listener.Addr().String())
p, _ := strconv.Atoi(port)
return s, Endpoint{IP: net.ParseIP("127.0.0.1").To4(), Port: int32(p)}
}
func backend(name string, ep Endpoint) []ServeBackend {
return []ServeBackend{{Key: name, Weight: 1, Endpoints: []Endpoint{ep}}}
}
func prefix(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr(v)}}
}
func exact(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathExact), Value: ptr(v)}}
}
func get(t *testing.T, url, host string, hdr map[string]string) (int, string, http.Header) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if host != "" {
req.Host = host
}
for k, v := range hdr {
req.Header.Set(k, v)
}
c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b), resp.Header
}
func TestProxyRoutesByPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
_, c := named(t, "C")
_, d := named(t, "D")
_, e := named(t, "E")
t0 := time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC)
routes := []ServeRoute{
{Key: "ns/first", Created: t0, Rules: []ServeRule{
{Matches: []HTTPRouteMatch{exact("/exact")}, Backends: backend("A", a)},
{Matches: []HTTPRouteMatch{prefix("/api")}, Backends: backend("B", b)},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("C", c)},
}},
{Key: "ns/second", Created: t0.Add(time.Minute), Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/api/v2")}, Backends: backend("D", d)},
{Matches: []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr("/api")},
Headers: []HTTPHeaderMatch{{Name: "X-Ver", Value: "2"}}}}, Backends: backend("E", e)},
}},
}
if f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8080, Routes: routes}}); len(f) != 0 {
t.Fatal(f)
}
base := "http://" + tp.at(8080)
for path, want := range map[string]string{
"/exact": "A", "/exactly": "C", "/api/x": "B", "/api": "B", "/api/v2/y": "D", "/apix": "C", "/": "C",
} {
if _, body, _ := get(t, base+path, "example.com", nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("%s went to %q, want %s", path, body, want)
}
}
// same prefix length: the match with more headers takes it, though its route is younger
if _, body, _ := get(t, base+"/api/x", "example.com", map[string]string{"X-Ver": "2"}); !strings.HasPrefix(body, "E ") {
t.Errorf("header match lost: %q", body)
}
}
func TestProxyHostPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, w := named(t, "W")
_, x := named(t, "X")
routes := []ServeRoute{
{Key: "ns/wild", Hostnames: []string{"*.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("W", w)}}},
{Key: "ns/exact", Hostnames: []string{"a.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("X", x)}}},
}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8081, Routes: routes}})
base := "http://" + tp.at(8081)
for host, want := range map[string]string{"a.example.com": "X", "A.Example.com:8081": "X", "b.example.com": "W"} {
if _, body, _ := get(t, base+"/", host, nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("host %s went to %q, want %s", host, body, want)
}
}
if code, _, _ := get(t, base+"/", "example.org", nil); code != http.StatusNotFound {
t.Errorf("a host no route names: %d, want 404", code)
}
}
func certSecret(t *testing.T, name, rv string) *coreapi.Secret {
s := tlsSecret(t, "gateway-system", strings.ReplaceAll(name, ".", "-"), name)
s.UID = types.UID("uid-" + name)
s.ResourceVersion = rv
return s
}
func dialTLS(t *testing.T, addr, sni string) (*x509.Certificate, error) {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: sni, InsecureSkipVerify: true})
if err != nil {
return nil, err
}
defer conn.Close()
return conn.ConnectionState().PeerCertificates[0], nil
}
func TestProxyTLSBySNIAndSwapsCertificates(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
listeners := func(certA *coreapi.Secret) []ServeListener {
return []ServeListener{
{Gateway: "ns/gw", Name: "a", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "a.test", Cert: certA,
Routes: []ServeRoute{{Key: "ns/a", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}},
{Gateway: "ns/gw", Name: "b", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "b.test", Cert: certSecret(t, "b.test", "1"),
Routes: []ServeRoute{{Key: "ns/b", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("B", b)}}}}},
}
}
first := certSecret(t, "a.test", "1")
if f := tp.Apply(listeners(first)); len(f) != 0 {
t.Fatal(f)
}
addr := tp.at(8443)
ca, err := dialTLS(t, addr, "a.test")
if err != nil || ca.Subject.CommonName != "a.test" {
t.Fatalf("SNI a.test: %v %v", ca, err)
}
cb, err := dialTLS(t, addr, "b.test")
if err != nil || cb.Subject.CommonName != "b.test" {
t.Fatalf("SNI b.test: %v %v", cb, err)
}
if _, err := dialTLS(t, addr, "c.test"); err == nil {
t.Fatal("a name no listener has must not get a certificate")
}
// requests go by listener, over HTTP/2 when the client offers it
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: "b.test"}, ForceAttemptHTTP2: true}}
req, _ := http.NewRequest("GET", "https://"+addr+"/", nil)
req.Host = "b.test"
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.HasPrefix(string(body), "B ") || !strings.Contains(string(body), "proto=https") || resp.ProtoMajor != 2 {
t.Fatalf("over HTTPS to b.test: %s %q", resp.Proto, body)
}
// a renewed Secret: new handshakes get the new certificate on the same, still-bound port
renewed := certSecret(t, "a.test", "2")
tp.Apply(listeners(renewed))
if tp.at(8443) != addr {
t.Fatal("the port was rebound for a certificate change")
}
cn, err := dialTLS(t, addr, "a.test")
if err != nil || cn.SerialNumber.Cmp(ca.SerialNumber) == 0 {
t.Fatalf("still the old certificate after the swap: %v", err)
}
}
func TestProxyRedirectsAndForwards(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/old")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}},
{Matches: []HTTPRouteMatch{prefix("/moved")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/new")}}}}},
{Matches: []HTTPRouteMatch{prefix("/v1")}, Backends: backend("A", a), Filters: []HTTPRouteFilter{
{Type: filterRequestHeaderModifier, RequestHeaderModifier: &HTTPHeaderFilter{
Set: []HTTPHeader{{Name: "X-Set", Value: "s"}}, Add: []HTTPHeader{{Name: "X-Added", Value: "two"}}, Remove: []string{"X-Remove-Me"}}},
{Type: filterResponseHeaderModifier, ResponseHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: "X-Resp", Value: "r"}}}},
{Type: filterURLRewrite, URLRewrite: &HTTPURLRewriteFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/v2")}}},
}},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 80, Routes: routes}})
base := "http://" + tp.at(80)
code, _, h := get(t, base+"/old/page?q=1", "git.levk.dev", nil)
if code != http.StatusMovedPermanently || h.Get("Location") != "https://git.levk.dev/old/page?q=1" {
t.Fatalf("scheme redirect: %d %q", code, h.Get("Location"))
}
code, _, h = get(t, base+"/moved/x", "git.levk.dev", nil)
if code != http.StatusFound || h.Get("Location") != "http://git.levk.dev/new/x" {
t.Fatalf("prefix redirect: %d %q", code, h.Get("Location"))
}
// the client's own claims about who it is are not passed on; the backend sees this hop's view
_, body, _ := get(t, base+"/", "git.levk.dev", map[string]string{"X-Forwarded-For": "6.6.6.6", "X-Real-IP": "6.6.6.6"})
if !strings.Contains(body, "host=git.levk.dev") || !strings.Contains(body, "xff=127.0.0.1 ") ||
!strings.Contains(body, "proto=http ") || !strings.Contains(body, "xhost=git.levk.dev") || !strings.Contains(body, "realip=127.0.0.1") {
t.Fatalf("forwarded headers: %q", body)
}
_, body, h = get(t, base+"/v1/thing", "git.levk.dev", map[string]string{"X-Added": "one", "X-Remove-Me": "x"})
if !strings.Contains(body, "path=/v2/thing") || !strings.Contains(body, "added=one,two") || !strings.Contains(body, "removed= ") || h.Get("X-Resp") != "r" {
t.Fatalf("filters: %q %v", body, h)
}
}
// The model refuses routes whose filters touch the forwarding headers; the proxy holds the line anyway.
func TestProxyForwardingHeadersSurviveFilters(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a),
Filters: []HTTPRouteFilter{{Type: filterRequestHeaderModifier, RequestHeaderModifier: &HTTPHeaderFilter{
Set: []HTTPHeader{{Name: "X-Real-IP", Value: "6.6.6.6"}, {Name: "x-forwarded-host", Value: "evil.example"}},
Add: []HTTPHeader{{Name: "X-Forwarded-For", Value: "6.6.6.6"}},
Remove: []string{"X-Forwarded-Proto"},
}}}}}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 80, Routes: routes}})
_, body, _ := get(t, "http://"+tp.at(80)+"/", "git.levk.dev", map[string]string{"X-Forwarded-For": "7.7.7.7"})
for _, want := range []string{"xff=127.0.0.1 ", "proto=http ", "xhost=git.levk.dev ", "realip=127.0.0.1 "} {
if !strings.Contains(body, want) {
t.Fatalf("a filter changed the forwarding headers: %q (want %q)", body, want)
}
}
}
func TestProxyAnswersWhatItCannotRoute(t *testing.T) {
tp := newTestProxy(t)
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/broken")}, Broken: true},
{Matches: []HTTPRouteMatch{prefix("/empty")}, Backends: []ServeBackend{{Key: "ns/svc:80", Weight: 1}}},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8082, Routes: routes}})
base := "http://" + tp.at(8082)
for path, want := range map[string]int{"/broken": 500, "/empty": 503, "/nothing": 404} {
if code, _, _ := get(t, base+path, "x", nil); code != want {
t.Errorf("%s: %d, want %d", path, code, want)
}
}
}
func TestProxyStreamsLargeBodies(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8083,
Routes: []ServeRoute{{Key: "ns/r", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}}})
const size = 32 << 20
resp, err := http.Post("http://"+tp.at(8083)+"/upload", "application/octet-stream", bytes.NewReader(make([]byte, size)))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), fmt.Sprintf("body=%d", size)) {
t.Fatalf("upload: %q", body)
}
}
func TestProxyBindFailureAndClose(t *testing.T) {
tp := newTestProxy(t)
tp.listen = func(addr string) (net.Listener, error) { return nil, fmt.Errorf("address in use") }
f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8084}})
if f["ns/gw/http"] == nil {
t.Fatalf("bind failure not reported: %v", f)
}
tp2 := newTestProxy(t)
tp2.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8085}})
addr := tp2.at(8085)
tp2.Apply(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err != nil {
return
}
c.Close()
time.Sleep(50 * time.Millisecond)
}
t.Fatal("a port no longer served still accepts connections")
}