From f5f4b7ea58118e79e63a5edeeee2812706874ae3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 15:48:36 +0000 Subject: [PATCH] feat(http): add --trusted-proxies to resolve the real client address Echo's RealIP trusted X-Forwarded-For and X-Real-Ip from any peer when no IPExtractor is set. That fed the login and registration rate limits and the API trace client address, so a client could dodge a limit by rotating a forged header. Add pkg/clientip, one resolver for the whole server. With --trusted-proxies / LOCALAI_TRUSTED_PROXIES (IPs and CIDRs, empty by default), the forwarded chain is read only when the TCP peer is listed. The client is the right-most X-Forwarded-For entry that is not a trusted proxy; a malformed entry stops the walk. With the flag unset every request resolves to its TCP peer. The resolver is set as Echo's IPExtractor and used by the access log (--log-client), so all consumers agree. Assisted-by: Claude:claude-sonnet-5-5 go gofmt --- core/cli/run.go | 4 +- core/config/application_config.go | 8 + core/http/access_log.go | 21 +-- core/http/access_log_internal_test.go | 28 +++- core/http/app.go | 12 +- core/http/middleware/trace.go | 4 +- .../routes/auth_ratelimit_clientip_test.go | 51 ++++++ docs/content/advanced/reverse-proxy-tls.md | 27 ++++ docs/content/reference/cli-reference.md | 3 +- pkg/clientip/clientip.go | 152 ++++++++++++++++++ pkg/clientip/clientip_test.go | 90 +++++++++++ 11 files changed, 376 insertions(+), 24 deletions(-) create mode 100644 core/http/routes/auth_ratelimit_clientip_test.go create mode 100644 pkg/clientip/clientip.go create mode 100644 pkg/clientip/clientip_test.go diff --git a/core/cli/run.go b/core/cli/run.go index 80a555f9bc..a0c703a46f 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -50,7 +50,8 @@ type RunCMD struct { AddonsOwnerAddress string `name:"addons-owner-address" env:"LOCALAI_ADDONS_OWNER_ADDRESS" help:"Address of the add-on owner carrier, as https://host:port with no path. Use a stable service name, never a pod address. Required for the owner and frontend roles" group:"addons"` AddonsOwnerListen string `name:"addons-owner-listen" env:"LOCALAI_ADDONS_OWNER_LISTEN" help:"Owner role only: the address the carrier listens on. Empty listens on all interfaces, on the port of --addons-owner-address" group:"addons"` AddonsCarrierDir string `name:"addons-carrier-dir" env:"LOCALAI_ADDONS_CARRIER_DIR" type:"path" help:"Directory with the carrier key set made by 'local-ai util addons-carrier-keys'. Required for the owner and frontend roles" group:"addons"` - LogClient bool `name:"log-client" env:"LOCALAI_LOG_CLIENT" default:"false" help:"Add the duration, client address, user agent and request id to the HTTP access log. Off by default. The address is the TCP peer, never read from X-Forwarded-For. Turn it on with --log-client" group:"logging"` + TrustedProxies string `name:"trusted-proxies" env:"LOCALAI_TRUSTED_PROXIES" default:"" help:"Comma-separated IPs and CIDRs of reverse proxies in front of LocalAI. Only when the TCP peer is in this list is the client address taken from X-Forwarded-For (right-most untrusted entry). Used by the access log, login rate limits and traces. Empty trusts nobody" group:"api"` + LogClient bool `name:"log-client" env:"LOCALAI_LOG_CLIENT" default:"false" help:"Add the duration, client address, user agent and request id to the HTTP access log. Off by default. The address is the TCP peer unless it is a trusted proxy (see --trusted-proxies). Turn it on with --log-client" group:"logging"` AutoUpgradeAddons bool `name:"auto-upgrade-addons" env:"LOCALAI_AUTO_UPGRADE_ADDONS" default:"false" help:"Upgrade installed add-ons on their own when a newer version appears in their library. Off by default; upgrades then stay manual" group:"addons"` AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"` AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"true" help:"Register the backend library on the first start, when the add-on owner has no sources.json yet. A build from master reads it as the backend source; a tagged build reads the backend gallery until the release cutover. Turn it off to read the backend gallery only" group:"addons"` @@ -397,6 +398,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { config.WithSystemState(systemState), config.WithContextSize(r.ContextSize), config.WithLogClient(r.LogClient), + config.WithTrustedProxies(r.TrustedProxies), config.WithDebug(ctx.Debug || (ctx.LogLevel != nil && *ctx.LogLevel == "debug")), config.WithGeneratedContentDir(r.GeneratedContentPath), config.WithUploadDir(r.UploadPath), diff --git a/core/config/application_config.go b/core/config/application_config.go index 13e382842e..9be9414c6a 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -143,6 +143,7 @@ type ApplicationConfig struct { F16 bool Debug bool LogClient bool + TrustedProxies string // comma-separated IPs/CIDRs whose forwarding headers are believed EnableTracing bool MaxConcurrentBackendRequests int TracingMaxItems int @@ -1076,6 +1077,13 @@ func WithDebug(debug bool) AppOption { } } +// WithTrustedProxies sets the proxies whose X-Forwarded-For is believed. +func WithTrustedProxies(list string) AppOption { + return func(o *ApplicationConfig) { + o.TrustedProxies = list + } +} + // WithLogClient adds the peer address and user agent to the HTTP access log. func WithLogClient(on bool) AppOption { return func(o *ApplicationConfig) { diff --git a/core/http/access_log.go b/core/http/access_log.go index ec314446e7..93dc365e8d 100644 --- a/core/http/access_log.go +++ b/core/http/access_log.go @@ -2,13 +2,13 @@ package http import ( "errors" - "net" "net/http" "strings" "time" "unicode" "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/pkg/clientip" "github.com/mudler/LocalAI/pkg/diagnostics" "github.com/mudler/xlog" ) @@ -21,9 +21,10 @@ const maxLoggedUserAgent = 80 const maxLoggedRequestID = 64 // accessLog logs one line per request. When logClient is true the line also -// carries the peer address and the user agent, so an unauthorized caller can +// carries the client address (the TCP peer, or the forwarded client when the +// peer is a trusted proxy) and the user agent, so an unauthorized caller can // be identified. -func accessLog(logClient bool) echo.MiddlewareFunc { +func accessLog(logClient bool, clients *clientip.Resolver) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { start := time.Now() @@ -60,7 +61,7 @@ func accessLog(logClient bool) echo.MiddlewareFunc { // ?api_key=... stay out of the log. attrs := []any{"method", req.Method, "path", req.URL.Path, "status", status} if logClient { - attrs = append(attrs, "duration", time.Since(start).String(), "remote", peerAddr(req), "user_agent", sanitizeLogValue(req.UserAgent(), maxLoggedUserAgent)) + attrs = append(attrs, "duration", time.Since(start).String(), "remote", clients.Resolve(req), "user_agent", sanitizeLogValue(req.UserAgent(), maxLoggedUserAgent)) if id := requestID(req, res); id != "" { attrs = append(attrs, "request_id", id) } @@ -76,18 +77,6 @@ func accessLog(logClient bool) echo.MiddlewareFunc { } } -// peerAddr returns the TCP peer address without the port. It does not use -// echo's Context.RealIP: with no IPExtractor configured, RealIP trusts the -// X-Forwarded-For and X-Real-Ip headers, which any client can forge. LocalAI -// has no trusted-proxy setting, so the peer address is the only safe value. -func peerAddr(req *http.Request) string { - host, _, err := net.SplitHostPort(req.RemoteAddr) - if err != nil { - return req.RemoteAddr - } - return host -} - // requestID returns the diagnostic id the server set on the response, or the // X-Request-Id the caller sent, or an empty string. func requestID(req *http.Request, res *echo.Response) string { diff --git a/core/http/access_log_internal_test.go b/core/http/access_log_internal_test.go index 3b9e1157ef..57cda21aa8 100644 --- a/core/http/access_log_internal_test.go +++ b/core/http/access_log_internal_test.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/pkg/clientip" "github.com/mudler/xlog" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -26,7 +27,7 @@ var _ = Describe("HTTP access log", func() { serve := func(logClient bool, mutate func(*http.Request)) string { logs.Reset() e := echo.New() - e.Use(accessLog(logClient)) + e.Use(accessLog(logClient, nil)) e.POST("/chat/completions", func(c echo.Context) error { return echo.NewHTTPError(http.StatusUnauthorized, "no key") }) @@ -56,6 +57,27 @@ var _ = Describe("HTTP access log", func() { Expect(line).NotTo(ContainSubstring("203.0.113")) }) + It("takes the client from X-Forwarded-For only when the peer is a trusted proxy", func() { + trusted, err := clientip.Parse("192.0.2.0/24") + Expect(err).NotTo(HaveOccurred()) + logs.Reset() + e := echo.New() + e.Use(accessLog(true, trusted)) + e.GET("/x", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "192.0.2.10:54321" + req.Header.Set("X-Forwarded-For", "198.51.100.7, 203.0.113.5, 192.0.2.20") + e.ServeHTTP(httptest.NewRecorder(), req) + Expect(logs.String()).To(ContainSubstring(`"remote":"203.0.113.5"`)) + + logs.Reset() + req = httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "198.51.100.99:1" + req.Header.Set("X-Forwarded-For", "203.0.113.5") + e.ServeHTTP(httptest.NewRecorder(), req) + Expect(logs.String()).To(ContainSubstring(`"remote":"198.51.100.99"`)) + }) + It("truncates the user agent to 80 characters", func() { line := serve(true, func(r *http.Request) { r.Header.Set("User-Agent", strings.Repeat("a", 500)) }) Expect(line).To(ContainSubstring(`"user_agent":"` + strings.Repeat("a", 80) + `"`)) @@ -99,14 +121,14 @@ var _ = Describe("HTTP access log", func() { }) }) -var _ = DescribeTable("peerAddr", +var _ = DescribeTable("client address with no trusted proxies", func(remote string, headers map[string]string, want string) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = remote for k, v := range headers { req.Header.Set(k, v) } - Expect(peerAddr(req)).To(Equal(want)) + Expect((*clientip.Resolver)(nil).Resolve(req)).To(Equal(want)) }, Entry("ipv4 with port", "192.0.2.10:1234", nil, "192.0.2.10"), Entry("ipv6 with port", "[2001:db8::1]:1234", nil, "2001:db8::1"), diff --git a/core/http/app.go b/core/http/app.go index 5745fec31c..e3ef461c3f 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -17,6 +17,7 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/mudler/LocalAI/pkg/clientip" "github.com/mudler/LocalAI/pkg/model" corebackend "github.com/mudler/LocalAI/core/backend" @@ -227,6 +228,15 @@ func setModelLoadingRetryAfter(loadErr *nodes.ModelLoadingError, c echo.Context) func API(application *application.Application) (*echo.Echo, error) { e := echo.New() + // One resolver decides the client address for every consumer (access log, + // login throttling, traces). Without an IPExtractor echo's RealIP trusts + // X-Forwarded-For and X-Real-Ip from anyone. + clientAddrs, err := clientip.Parse(application.ApplicationConfig().TrustedProxies) + if err != nil { + return nil, fmt.Errorf("invalid --trusted-proxies: %w", err) + } + e.IPExtractor = clientAddrs.Extractor() + // Set body limit e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ Limit: fmt.Sprintf("%dM", application.ApplicationConfig().GeneralUploadLimitMB()), @@ -338,7 +348,7 @@ func API(application *application.Application) (*echo.Echo, error) { } // Custom logger middleware using xlog - e.Use(accessLog(application.ApplicationConfig().LogClient)) + e.Use(accessLog(application.ApplicationConfig().LogClient, clientAddrs)) // Recover middleware if !application.ApplicationConfig().Debug { diff --git a/core/http/middleware/trace.go b/core/http/middleware/trace.go index caa8af53bd..b79cb472ef 100644 --- a/core/http/middleware/trace.go +++ b/core/http/middleware/trace.go @@ -54,8 +54,8 @@ type APIExchange struct { Error string `json:"error,omitempty"` UserID string `json:"user_id,omitempty"` UserName string `json:"user_name,omitempty"` - // ClientIP is the caller's address as resolved by echo (honours - // X-Forwarded-For / X-Real-IP behind a trusted proxy), and UserAgent + // ClientIP is the caller's address as resolved by echo's IPExtractor (forwarding + // headers count only from a trusted proxy), and UserAgent // is the raw User-Agent header. Both are surfaced in the admin Traces // UI so an operator can tell who/what issued each request. ClientIP string `json:"client_ip,omitempty"` diff --git a/core/http/routes/auth_ratelimit_clientip_test.go b/core/http/routes/auth_ratelimit_clientip_test.go new file mode 100644 index 0000000000..7b0fdb04a1 --- /dev/null +++ b/core/http/routes/auth_ratelimit_clientip_test.go @@ -0,0 +1,51 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/pkg/clientip" +) + +// The login throttle keys on echo's RealIP. With the shared resolver set as +// the IPExtractor, a client cannot dodge the limit by rotating a forged +// X-Forwarded-For, and clients behind a trusted proxy are throttled apart. +func TestRateLimitKeysOnResolvedClient(t *testing.T) { + run := func(trusted string, peer string, xffs []string) []int { + r, err := clientip.Parse(trusted) + if err != nil { + t.Fatal(err) + } + e := echo.New() + e.IPExtractor = r.Extractor() + e.Use(rateLimitMiddleware(newRateLimiter(time.Minute, 2))) + e.POST("/login", func(c echo.Context) error { return c.NoContent(http.StatusOK) }) + var codes []int + for _, x := range xffs { + req := httptest.NewRequest(http.MethodPost, "/login", nil) + req.RemoteAddr = peer + req.Header.Set("X-Forwarded-For", x) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + codes = append(codes, rec.Code) + } + return codes + } + + // Untrusted peer rotating forged addresses is still one client. + got := run("", "192.0.2.10:1", []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}) + if got[2] != http.StatusTooManyRequests { + t.Fatalf("forged X-Forwarded-For bypassed the limit: %v", got) + } + // Behind a trusted proxy, distinct clients get distinct budgets. + got = run("10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.1", "203.0.113.1"}) + want := []int{200, 200, 200, 200, 429} + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} diff --git a/docs/content/advanced/reverse-proxy-tls.md b/docs/content/advanced/reverse-proxy-tls.md index ccc0fb8284..f526218482 100644 --- a/docs/content/advanced/reverse-proxy-tls.md +++ b/docs/content/advanced/reverse-proxy-tls.md @@ -34,6 +34,33 @@ LocalAI determines these in this order: A reverse-proxy subpath mount is supported via `X-Forwarded-Prefix`; it is appended to `LOCALAI_BASE_URL` when both are present. +## Finding the client address + +Behind a proxy, the TCP peer of every request is the proxy. LocalAI needs the +real client address for the access log (`--log-client`), the login rate +limits and the API traces. `X-Forwarded-For` can be set by anyone, so LocalAI +reads it only when the TCP peer is a proxy you list: + +```bash +local-ai run --trusted-proxies 10.0.0.0/8,192.168.1.5 +# or: LOCALAI_TRUSTED_PROXIES=10.0.0.0/8,192.168.1.5 +``` + +The value is a comma-separated list of IP addresses and CIDR ranges (IPv4 or +IPv6). When the peer is in the list, LocalAI walks `X-Forwarded-For` from the +right, skips every entry that is also in the list, and takes the first other +entry as the client. If there is no `X-Forwarded-For`, it uses `X-Real-Ip`. +If an entry is not a valid address, LocalAI stops there and uses the last +trusted hop. When the peer is not in the list, or the list is empty (the +default), the headers are ignored and the client is the peer. + +List every proxy that can reach LocalAI directly, and no more: a listed +address can claim any client address. Make your proxy append to +`X-Forwarded-For` (nginx: `proxy_set_header X-Forwarded-For +$proxy_add_x_forwarded_for;`) and not replace it. Without this setting, all +clients behind the proxy share one rate-limit bucket, because they all appear +to come from the proxy. + ## Required Headers Your reverse proxy must forward these headers to LocalAI: diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 21a6586b77..726ca02fb8 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -20,7 +20,8 @@ model, see [Model Configuration]({{% relref "advanced/model-configuration" %}}). |-----------|---------|-------------|----------------------| | `-h, --help` | | Show context-sensitive help | | | `--log-level` | `info` | Set the level of logs to output [error,warn,info,debug,trace] | `$LOCALAI_LOG_LEVEL` | -| `--log-client` | `false` | `local-ai run` only. Add `duration`, `remote` (the TCP peer address), `user_agent` (control characters replaced, cut to 80 characters), and `request_id` (when the request has one) to the `HTTP request` log line, so you can tell who sent a request that was refused. The address never comes from `X-Forwarded-For` or `X-Real-Ip`, so behind a reverse proxy it is the proxy address. Authorization headers, cookies, query strings and bodies are never logged. | `$LOCALAI_LOG_CLIENT` | +| `--log-client` | `false` | `local-ai run` only. Add `duration`, `remote` (the TCP peer address, or the real client when the peer is a trusted proxy), `user_agent` (control characters replaced, cut to 80 characters), and `request_id` (when the request has one) to the `HTTP request` log line, so you can tell who sent a request that was refused. Forwarding headers are read only from a peer listed in `--trusted-proxies`, so behind a reverse proxy the address is the proxy address until you set it. Authorization headers, cookies, query strings and bodies are never logged. | `$LOCALAI_LOG_CLIENT` | +| `--trusted-proxies` | | Comma-separated IP addresses and CIDR ranges of the reverse proxies in front of LocalAI. When the TCP peer is in the list, the client address is the right-most `X-Forwarded-For` entry that is not itself in the list (`X-Real-Ip` is used when there is no `X-Forwarded-For`). Otherwise it is the peer. Empty (the default) trusts nobody. The address is used by the access log, the login and registration rate limits and the API traces. See [Running behind a proxy]({{% relref "advanced/reverse-proxy-tls" %}}#finding-the-client-address). An invalid value stops startup. | `$LOCALAI_TRUSTED_PROXIES` | | `--credentials-file` | | YAML file with credentials for private registries, galleries and download hosts. See [Private Registries and Galleries]({{% relref "advanced/private-sources" %}}) | `$LOCALAI_CREDENTIALS_FILE` | | `--debug` | `false` | **DEPRECATED** - Use `--log-level=debug` instead. Enable debug logging | `$LOCALAI_DEBUG`, `$DEBUG` | diff --git a/pkg/clientip/clientip.go b/pkg/clientip/clientip.go new file mode 100644 index 0000000000..f2879f9dc5 --- /dev/null +++ b/pkg/clientip/clientip.go @@ -0,0 +1,152 @@ +// Package clientip finds the address of the real client of an HTTP request. +// +// Forwarding headers such as X-Forwarded-For can be set by anyone. They are +// only meaningful when the TCP peer is a reverse proxy the operator trusts. +// This package is the single place that decides that. Everything in the +// server that needs a client address (access log, rate limits, traces, echo's +// Context.RealIP) must go through a Resolver. +package clientip + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "strings" + + "github.com/labstack/echo/v4" +) + +// Resolver resolves the client address of a request. The zero value, and a +// Resolver built from an empty list, trusts nobody: it always returns the TCP +// peer. +type Resolver struct { + trusted []netip.Prefix +} + +// Parse builds a Resolver from a comma-separated list of IP addresses and +// CIDR ranges. An empty string gives a Resolver that trusts nobody. +func Parse(list string) (*Resolver, error) { + var items []string + for _, s := range strings.Split(list, ",") { + if s = strings.TrimSpace(s); s != "" { + items = append(items, s) + } + } + return New(items) +} + +// New builds a Resolver from IP addresses and CIDR ranges. +func New(items []string) (*Resolver, error) { + r := &Resolver{} + for _, s := range items { + s = strings.TrimSpace(s) + if s == "" { + continue + } + var p netip.Prefix + if strings.Contains(s, "/") { + pp, err := netip.ParsePrefix(s) + if err != nil { + return nil, fmt.Errorf("trusted proxy %q: %w", s, err) + } + p = pp + } else { + a, err := netip.ParseAddr(s) + if err != nil { + return nil, fmt.Errorf("trusted proxy %q: not an IP address or CIDR", s) + } + p = netip.PrefixFrom(a, a.BitLen()) + } + // Treat ::ffff:a.b.c.d like a.b.c.d, in both the list and the lookups. + if p.Addr().Is4In6() { + if p.Bits() < 96 { + return nil, fmt.Errorf("trusted proxy %q: IPv4-mapped range must be at least /96", s) + } + p = netip.PrefixFrom(p.Addr().Unmap(), p.Bits()-96) + } + r.trusted = append(r.trusted, p.Masked()) + } + return r, nil +} + +// Enabled reports whether any proxy is trusted. +func (r *Resolver) Enabled() bool { return r != nil && len(r.trusted) > 0 } + +func (r *Resolver) isTrusted(a netip.Addr) bool { + if r == nil { + return false + } + a = a.Unmap().WithZone("") + for _, p := range r.trusted { + if p.Contains(a) { + return true + } + } + return false +} + +// Resolve returns the client address of req, without port. When the TCP peer +// is not trusted, or the list is empty, that is the peer. When it is trusted, +// the result is the right-most X-Forwarded-For entry that is not itself a +// trusted proxy. If there is no X-Forwarded-For, X-Real-IP is used. If an +// entry cannot be parsed, the walk stops and the closest trusted hop is +// returned, because nothing left of a forged or broken entry can be believed. +func (r *Resolver) Resolve(req *http.Request) string { + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + host = req.RemoteAddr + } + peer, err := netip.ParseAddr(host) + if err != nil || !r.isTrusted(peer) { + return host + } + hop := peer.Unmap().WithZone("") + + var entries []string + for _, v := range req.Header.Values(echo.HeaderXForwardedFor) { + entries = append(entries, strings.Split(v, ",")...) + } + if len(entries) == 0 { + if a, ok := parseAddr(req.Header.Get(echo.HeaderXRealIP)); ok { + return a.String() + } + return hop.String() + } + for i := len(entries) - 1; i >= 0; i-- { + a, ok := parseAddr(entries[i]) + if !ok { + return hop.String() + } + if !r.isTrusted(a) { + return a.String() + } + hop = a + } + // Every hop is a trusted proxy: the client is the left-most one. + return hop.String() +} + +// Extractor returns an echo.IPExtractor backed by the Resolver. Set it as +// Echo.IPExtractor so Context.RealIP stops trusting raw headers. +func (r *Resolver) Extractor() echo.IPExtractor { + return func(req *http.Request) string { return r.Resolve(req) } +} + +// parseAddr parses one forwarding header value: an IP, optionally with a +// port, in brackets for IPv6, with an optional zone. +func parseAddr(s string) (netip.Addr, bool) { + s = strings.TrimSpace(s) + if s == "" { + return netip.Addr{}, false + } + if ap, err := netip.ParseAddrPort(s); err == nil { + return ap.Addr().Unmap().WithZone(""), true + } + s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]") + a, err := netip.ParseAddr(s) + if err != nil { + return netip.Addr{}, false + } + return a.Unmap().WithZone(""), true +} diff --git a/pkg/clientip/clientip_test.go b/pkg/clientip/clientip_test.go new file mode 100644 index 0000000000..134be5c6d7 --- /dev/null +++ b/pkg/clientip/clientip_test.go @@ -0,0 +1,90 @@ +package clientip + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolve(t *testing.T) { + cases := []struct { + name string + trusted string + peer string + xff []string + realIP string + want string + }{ + {"empty list ignores xff", "", "192.0.2.10:1234", []string{"203.0.113.9"}, "203.0.113.8", "192.0.2.10"}, + {"untrusted peer spoofs xff", "10.0.0.0/8", "192.0.2.10:1234", []string{"203.0.113.9"}, "203.0.113.8", "192.0.2.10"}, + {"trusted peer, one hop", "10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.9"}, "", "203.0.113.9"}, + {"client forges left, proxy appends real", "10.0.0.0/8", "10.0.0.5:1", []string{"1.1.1.1, 203.0.113.9"}, "", "203.0.113.9"}, + {"multiple hops skip trusted", "10.0.0.0/8,192.168.1.1", "10.0.0.5:1", []string{"203.0.113.9, 10.1.1.1, 192.168.1.1"}, "", "203.0.113.9"}, + {"multiple header lines", "10.0.0.0/8", "10.0.0.5:1", []string{"1.1.1.1", "203.0.113.9, 10.2.2.2"}, "", "203.0.113.9"}, + {"all hops trusted gives left-most", "10.0.0.0/8", "10.0.0.5:1", []string{"10.9.9.9, 10.1.1.1"}, "", "10.9.9.9"}, + {"trusted peer, no headers", "10.0.0.0/8", "10.0.0.5:1", nil, "", "10.0.0.5"}, + {"x-real-ip fallback", "10.0.0.0/8", "10.0.0.5:1", nil, "203.0.113.8", "203.0.113.8"}, + {"x-real-ip ignored when xff present", "10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.9"}, "198.51.100.1", "203.0.113.9"}, + {"x-real-ip ignored from untrusted peer", "10.0.0.0/8", "192.0.2.10:1", nil, "203.0.113.8", "192.0.2.10"}, + {"single IP in list", "192.0.2.10", "192.0.2.10:1", []string{"203.0.113.9"}, "", "203.0.113.9"}, + {"ipv6 client", "10.0.0.0/8", "10.0.0.5:1", []string{"2001:db8::1"}, "", "2001:db8::1"}, + {"ipv6 client with brackets and port", "10.0.0.0/8", "10.0.0.5:1", []string{"[2001:db8::1]:5555"}, "", "2001:db8::1"}, + {"ipv4 client with port", "10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.9:4444"}, "", "203.0.113.9"}, + {"ipv6 trusted peer and range", "2001:db8:ff::/48", "[2001:db8:ff::7]:1", []string{"2001:db8:1::5, 2001:db8:ff::8"}, "", "2001:db8:1::5"}, + {"ipv6 untrusted peer", "2001:db8:ff::/48", "[2001:db8:aa::7]:1", []string{"203.0.113.9"}, "", "2001:db8:aa::7"}, + {"ipv4-mapped peer is trusted as ipv4", "10.0.0.0/8", "[::ffff:10.0.0.5]:1", []string{"203.0.113.9"}, "", "203.0.113.9"}, + {"ipv6 zone stripped", "10.0.0.0/8", "10.0.0.5:1", []string{"fe80::1%eth0"}, "", "fe80::1"}, + {"malformed right-most stops the walk", "10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.9, garbage"}, "", "10.0.0.5"}, + {"malformed after trusted hop returns that hop", "10.0.0.0/8", "10.0.0.5:1", []string{"garbage, 10.4.4.4"}, "", "10.4.4.4"}, + {"empty entry", "10.0.0.0/8", "10.0.0.5:1", []string{"203.0.113.9,"}, "", "10.0.0.5"}, + {"whitespace only xff", "10.0.0.0/8", "10.0.0.5:1", []string{" "}, "", "10.0.0.5"}, + {"hostname is not an address", "10.0.0.0/8", "10.0.0.5:1", []string{"evil.example.com"}, "", "10.0.0.5"}, + {"peer without port", "10.0.0.0/8", "10.0.0.5", []string{"203.0.113.9"}, "", "203.0.113.9"}, + {"unparsable peer is returned as is", "10.0.0.0/8", "pipe", []string{"203.0.113.9"}, "", "pipe"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, err := Parse(tc.trusted) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = tc.peer + for _, v := range tc.xff { + req.Header.Add("X-Forwarded-For", v) + } + if tc.realIP != "" { + req.Header.Set("X-Real-Ip", tc.realIP) + } + if got := r.Resolve(req); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestNilResolverTrustsNobody(t *testing.T) { + var r *Resolver + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1" + req.Header.Set("X-Forwarded-For", "203.0.113.9") + if got := r.Resolve(req); got != "192.0.2.1" { + t.Fatalf("got %q", got) + } + if r.Enabled() { + t.Fatal("nil resolver must be disabled") + } +} + +func TestParseErrors(t *testing.T) { + for _, s := range []string{"nope", "10.0.0.0/33", "10.0.0.1, bad", "::ffff:0:0/64"} { + if _, err := Parse(s); err == nil { + t.Errorf("Parse(%q): expected an error", s) + } + } + for _, s := range []string{"", " , ", "10.0.0.0/8, 2001:db8::/32 ,192.0.2.1", "::ffff:10.0.0.0/104"} { + if _, err := Parse(s); err != nil { + t.Errorf("Parse(%q): %v", s, err) + } + } +}