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
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 15:48:38 +00:00
1 parent 164d51f263
commit f5f4b7ea58
11 files changed
+376 -24

No files matched your search

+3 -1
View File
@@ -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),
+8
View File
@@ -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) {
+5 -16
View File
@@ -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 {
+25 -3
View File
@@ -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"),
+11 -1
View File
@@ -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 {
+2 -2
View File
@@ -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"`
@@ -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)
}
}
}
@@ -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:
+2 -1
View File
@@ -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` |
+152
View File
@@ -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
}
+90
View File
@@ -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)
}
}
}