mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
11 files changed
+376
-24
No files matched your search
+3
-1
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user