Files
weave/lb/gateway_proxy.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

548 lines
15 KiB
Go

package lb
import (
"context"
"crypto/tls"
"fmt"
"log"
"math/rand"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
coreapi "k8s.io/api/core/v1"
"github.com/rajch/weave/common"
)
// Proxy serves the HTTP and HTTPS listeners of the Gateways this node holds. TLS is terminated with the
// listener's certificate, chosen by SNI and swapped in place when its Secret changes (no restart, no rebind).
// Requests are routed by the attached HTTPRoutes in the order the spec prescribes and passed to a ready endpoint
// of the chosen backend, with the client's own address in X-Forwarded-For. Bodies are streamed, never limited.
type Proxy struct {
mu sync.Mutex
servers map[string]*portServer // by "address:port"
// listen binds an address (swappable in tests)
listen func(addr string) (net.Listener, error)
transport http.RoundTripper
certs map[string]*tls.Certificate // parsed certificates, by Secret uid@resourceVersion
rr sync.Map // backend key -> *uint64, for round robin over its endpoints
}
func NewProxy() *Proxy {
return &Proxy{
servers: map[string]*portServer{},
certs: map[string]*tls.Certificate{},
listen: func(addr string) (net.Listener, error) { return net.Listen("tcp", addr) },
transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: time.Second,
},
}
}
// portServer is one bound address:port and what it currently serves.
type portServer struct {
addr string
https bool
ln net.Listener
srv *http.Server
cfg atomic.Pointer[portConfig]
}
// portConfig is everything one address:port serves: its listeners, most specific hostname first.
type portConfig struct {
listeners []*liveListener
}
type liveListener struct {
spec ServeListener
cert *tls.Certificate
}
// Apply serves exactly these listeners: ports no longer listed are closed, new ones bound, and every served
// port's routes and certificates replaced at once. It returns, by "namespace/gateway/listener", the listeners
// it could not bind.
func (p *Proxy) Apply(listeners []ServeListener) map[string]error {
p.mu.Lock()
defer p.mu.Unlock()
failed := map[string]error{}
byAddr := map[string][]ServeListener{}
for _, l := range listeners {
a := net.JoinHostPort(l.Address.String(), strconv.Itoa(int(l.Port)))
byAddr[a] = append(byAddr[a], l)
}
for a, s := range p.servers {
if group, ok := byAddr[a]; !ok || group[0].HTTPS != s.https {
s.close()
delete(p.servers, a)
}
}
used := map[string]bool{}
for a, group := range byAddr {
cfg := p.compile(group, used)
s := p.servers[a]
if s == nil {
var err error
if s, err = p.start(a, group[0].HTTPS); err != nil {
for _, l := range group {
failed[l.Gateway+"/"+l.Name] = err
}
continue
}
p.servers[a] = s
}
s.cfg.Store(cfg)
}
// forget certificates no listener uses any more
for k := range p.certs {
if !used[k] {
delete(p.certs, k)
}
}
return failed
}
// Close stops serving everything.
func (p *Proxy) Close() {
p.Apply(nil)
}
func (p *Proxy) compile(group []ServeListener, used map[string]bool) *portConfig {
cfg := &portConfig{}
for _, l := range group {
ll := &liveListener{spec: l}
if l.HTTPS && l.Cert != nil {
ll.cert = p.cert(l.Cert)
used[certKey(l.Cert)] = true
}
cfg.listeners = append(cfg.listeners, ll)
}
// most specific first: exact hostnames, then wildcards (longest first), then the catch-all
sort.SliceStable(cfg.listeners, func(i, j int) bool {
return hostRank(cfg.listeners[i].spec.Hostname) > hostRank(cfg.listeners[j].spec.Hostname)
})
return cfg
}
func certKey(s *coreapi.Secret) string {
return string(s.UID) + "@" + s.ResourceVersion + "/" + s.Namespace + "/" + s.Name
}
// cert parses a Secret's certificate once per version of the Secret.
func (p *Proxy) cert(s *coreapi.Secret) *tls.Certificate {
k := certKey(s)
if c, ok := p.certs[k]; ok {
return c
}
c, err := tls.X509KeyPair(s.Data[coreapi.TLSCertKey], s.Data[coreapi.TLSPrivateKeyKey])
if err != nil {
common.Log.Warnf("lb: gateway certificate %s/%s: %v", s.Namespace, s.Name, err)
return nil
}
p.certs[k] = &c
return &c
}
// hostRank orders listener hostnames: exact (by length) above wildcards (by length) above none.
func hostRank(h string) int {
switch {
case h == "":
return 0
case strings.HasPrefix(h, "*."):
return 1000 + len(h)
}
return 100000 + len(h)
}
func (p *Proxy) start(addr string, https bool) (*portServer, error) {
ln, err := p.listen(addr)
if err != nil {
return nil, err
}
s := &portServer{addr: addr, https: https, ln: ln}
s.cfg.Store(&portConfig{})
s.srv = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p.serve(s, w, r) }),
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
// handshakes from scanners and the like would flood the log
ErrorLog: log.New(debugWriter{}, "lb: gateway "+addr+": ", 0),
}
if https {
s.srv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.certificate}
go func() {
if err := s.srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed {
common.Log.Errorf("lb: gateway %s: %v", addr, err)
}
}()
} else {
go func() {
if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed {
common.Log.Errorf("lb: gateway %s: %v", addr, err)
}
}()
}
common.Log.Infof("lb: gateway serving %s (%s)", addr, map[bool]string{true: "HTTPS", false: "HTTP"}[https])
return s, nil
}
func (s *portServer) close() {
srv := s.srv
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = srv.Close()
}()
common.Log.Infof("lb: gateway no longer serving %s", s.addr)
}
type debugWriter struct{}
func (debugWriter) Write(b []byte) (int, error) {
common.Log.Debugf("%s", strings.TrimRight(string(b), "\n"))
return len(b), nil
}
// listenerFor picks the listener a name (SNI or Host) reaches on a port, most specific hostname first.
func (c *portConfig) listenerFor(name string) *liveListener {
name = strings.ToLower(name)
for _, l := range c.listeners {
if l.spec.Hostname == "" || hostMatches(l.spec.Hostname, name) {
return l
}
}
return nil
}
// certificate answers a TLS handshake with the certificate of the listener its SNI names.
func (s *portServer) certificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cfg := s.cfg.Load()
l := cfg.listenerFor(hello.ServerName)
if l == nil || l.cert == nil {
return nil, fmt.Errorf("no listener for %q", hello.ServerName)
}
return l.cert, nil
}
// candidate is one match of one rule that a request satisfies, with what decides its precedence.
type candidate struct {
route *ServeRoute
rule *ServeRule
ruleIdx int
// the matched hostname: characters if not a wildcard, and characters
hostExact, hostLen int
exact bool
prefixLen int
method bool
headers, query int
prefix string // the path prefix matched (for ReplacePrefixMatch)
}
func (p *Proxy) serve(s *portServer, w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.Load()
host := hostOnly(r.Host)
name := host
if r.TLS != nil && r.TLS.ServerName != "" {
name = r.TLS.ServerName
}
l := cfg.listenerFor(name)
if l == nil {
http.Error(w, "no listener for this host", http.StatusNotFound)
return
}
c := pick(l, host, r)
if c == nil {
http.Error(w, "no route", http.StatusNotFound)
return
}
for _, f := range c.rule.Filters {
if f.Type == filterRequestRedirect && f.RequestRedirect != nil {
to, code := redirect(r, f.RequestRedirect, l.spec.Port, c.prefix)
http.Redirect(w, r, to, code)
return
}
}
b := chooseBackend(c.rule.Backends)
if b == nil {
// no resolved backend to send it to: the spec's answer for unresolved backends
http.Error(w, "no usable backend", http.StatusInternalServerError)
return
}
if len(b.Endpoints) == 0 {
http.Error(w, "no ready endpoint", http.StatusServiceUnavailable)
return
}
n, _ := p.rr.LoadOrStore(b.Key, new(uint64))
ep := b.Endpoints[int(atomic.AddUint64(n.(*uint64), 1)-1)%len(b.Endpoints)]
filters := append(append([]HTTPRouteFilter(nil), c.rule.Filters...), b.Filters...)
rp := &httputil.ReverseProxy{
Transport: p.transport,
FlushInterval: -1, // stream as it comes (git, server-sent events)
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: ep.String()})
pr.Out.Host = pr.In.Host // forwarded unmodified unless a filter says otherwise
for _, f := range filters {
switch {
case f.Type == filterRequestHeaderModifier && f.RequestHeaderModifier != nil:
modifyHeaders(pr.Out.Header, f.RequestHeaderModifier)
case f.Type == filterURLRewrite && f.URLRewrite != nil:
if f.URLRewrite.Hostname != nil {
pr.Out.Host = *f.URLRewrite.Hostname
}
if f.URLRewrite.Path != nil {
pr.Out.URL.Path = rewritePath(pr.In.URL.Path, f.URLRewrite.Path, c.prefix)
pr.Out.URL.RawPath = ""
}
}
}
// After the filters, so none can change these (the model already refuses routes that try): only this
// hop's view of the connection is passed on. Cleared first, as SetXForwarded appends to an
// X-Forwarded-For already there.
for _, h := range forwardingHeaders {
pr.Out.Header.Del(h)
}
pr.SetXForwarded()
if ip, _, err := net.SplitHostPort(pr.In.RemoteAddr); err == nil {
pr.Out.Header.Set("X-Real-IP", ip)
}
},
ModifyResponse: func(resp *http.Response) error {
for _, f := range filters {
if f.Type == filterResponseHeaderModifier && f.ResponseHeaderModifier != nil {
modifyHeaders(resp.Header, f.ResponseHeaderModifier)
}
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
common.Log.Debugf("lb: gateway %s -> %s: %v", s.addr, ep, err)
w.WriteHeader(http.StatusBadGateway)
},
}
rp.ServeHTTP(w, r)
}
// pick is the match that takes the request, by the spec's precedence: the most specific matching hostname, then
// an exact path, the longest prefix, a method, the most header matches, the most query matches, then the oldest
// route, its namespace/name, and the first rule.
func pick(l *liveListener, host string, r *http.Request) *candidate {
var best *candidate
for ri := range l.spec.Routes {
route := &l.spec.Routes[ri]
hostExact, hostLen, ok := routeHost(route, l.spec.Hostname, host)
if !ok {
continue
}
for i := range route.Rules {
rule := &route.Rules[i]
for _, m := range rule.Matches {
c, ok := matches(m, r)
if !ok {
continue
}
c.route, c.rule, c.ruleIdx, c.hostExact, c.hostLen = route, rule, i, hostExact, hostLen
if best == nil || better(c, *best) {
cc := c
best = &cc
}
}
}
}
return best
}
func better(a, b candidate) bool {
switch {
case a.hostExact != b.hostExact:
return a.hostExact > b.hostExact
case a.hostLen != b.hostLen:
return a.hostLen > b.hostLen
case a.exact != b.exact:
return a.exact
case a.prefixLen != b.prefixLen:
return a.prefixLen > b.prefixLen
case a.method != b.method:
return a.method
case a.headers != b.headers:
return a.headers > b.headers
case a.query != b.query:
return a.query > b.query
case !a.route.Created.Equal(b.route.Created):
return a.route.Created.Before(b.route.Created)
case a.route.Key != b.route.Key:
return a.route.Key < b.route.Key
}
return a.ruleIdx < b.ruleIdx
}
// routeHost reports whether a route answers this host on its listener, and how specifically.
func routeHost(route *ServeRoute, listenerHost, host string) (int, int, bool) {
names := route.Hostnames
if len(names) == 0 {
if listenerHost == "" {
return 0, 0, true
}
names = []string{listenerHost}
}
exact, length, ok := 0, 0, false
for _, h := range names {
if !hostMatches(h, host) {
continue
}
ok = true
e := 0
if !strings.HasPrefix(h, "*.") {
e = len(h)
}
if e > exact || (e == exact && len(h) > length) {
exact, length = e, len(h)
}
}
return exact, length, ok
}
func matches(m HTTPRouteMatch, r *http.Request) (candidate, bool) {
var c candidate
path, value := r.URL.Path, str(m.Path.Value)
switch str(m.Path.Type) {
case pathExact:
if path != value {
return c, false
}
c.exact = true
c.prefixLen = len(value)
default:
trimmed := strings.TrimSuffix(value, "/")
if trimmed != "" && path != trimmed && !strings.HasPrefix(path, trimmed+"/") {
return c, false
}
c.prefixLen = len(value)
c.prefix = value
}
if m.Method != nil {
if r.Method != *m.Method {
return c, false
}
c.method = true
}
for _, h := range m.Headers {
if r.Header.Get(h.Name) != h.Value {
return c, false
}
}
c.headers = len(m.Headers)
q := r.URL.Query()
for _, qp := range m.QueryParams {
if q.Get(qp.Name) != qp.Value {
return c, false
}
}
c.query = len(m.QueryParams)
return c, true
}
// chooseBackend picks a backend by weight (nil when there is none to pick).
func chooseBackend(bs []ServeBackend) *ServeBackend {
total := int32(0)
for _, b := range bs {
if b.Weight > 0 {
total += b.Weight
}
}
if total == 0 {
return nil
}
n := rand.Int31n(total)
for i := range bs {
if bs[i].Weight <= 0 {
continue
}
if n < bs[i].Weight {
return &bs[i]
}
n -= bs[i].Weight
}
return nil
}
// redirect is where a RequestRedirect filter sends a request, and with which status.
func redirect(r *http.Request, f *HTTPRequestRedirectFilter, listenerPort int32, prefix string) (string, int) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
port := int(listenerPort)
if f.Scheme != nil {
// a new scheme brings its well-known port, unless a port is given
scheme = *f.Scheme
port = map[string]int{"http": 80, "https": 443}[scheme]
}
if f.Port != nil {
port = int(*f.Port)
}
host := hostOnly(r.Host)
if f.Hostname != nil {
host = *f.Hostname
}
if !(scheme == "http" && port == 80) && !(scheme == "https" && port == 443) && port != 0 {
host = net.JoinHostPort(host, strconv.Itoa(port))
}
path := r.URL.Path
if f.Path != nil {
path = rewritePath(path, f.Path, prefix)
}
code := http.StatusFound
if f.StatusCode != nil {
code = *f.StatusCode
}
return (&url.URL{Scheme: scheme, Host: host, Path: path, RawQuery: r.URL.RawQuery}).String(), code
}
// rewritePath applies a path modifier: the whole path, or the matched prefix replaced.
func rewritePath(path string, m *HTTPPathModifier, prefix string) string {
switch m.Type {
case "ReplaceFullPath":
return str(m.ReplaceFullPath)
case "ReplacePrefixMatch":
rest := strings.TrimPrefix(path, strings.TrimSuffix(prefix, "/"))
out := strings.TrimSuffix(str(m.ReplacePrefixMatch), "/") + rest
if !strings.HasPrefix(out, "/") {
out = "/" + out
}
return out
}
return path
}
func modifyHeaders(h http.Header, f *HTTPHeaderFilter) {
for _, s := range f.Set {
h.Set(s.Name, s.Value)
}
for _, a := range f.Add {
h.Add(a.Name, a.Value)
}
for _, name := range f.Remove {
h.Del(name)
}
}
// hostOnly is a Host header without its port, lowercased (the spec ignores the port when matching).
func hostOnly(h string) string {
if host, _, err := net.SplitHostPort(h); err == nil {
h = host
}
return strings.ToLower(strings.TrimSuffix(h, "."))
}