Secure client IP extraction for net/http requests with trusted proxy validation, configurable source priority, and optional logging/metrics.
This project is pre-v1.0.0 and still before v0.1.0, so public APIs may change as the package evolves.
Any breaking changes will be called out in CHANGELOG.md.
go get github.com/abczzz13/clientipOptional Prometheus adapter:
go get github.com/abczzz13/clientip/prometheusimport "github.com/abczzz13/clientip"By default, New() extracts from RemoteAddr only.
Use these when you want setup by deployment type instead of low-level options:
PresetDirectConnection()app receives traffic directly (no trusted proxy headers)PresetLoopbackReverseProxy()reverse proxy on same host (127.0.0.1/::1)PresetVMReverseProxy()typical VM/private-network reverse proxy setupPresetPreferredHeaderThenXFFLax("X-Frontend-IP")prefer custom header, thenX-Forwarded-For, thenRemoteAddr(lax fallback)
| If your setup looks like... | Start with... |
|---|---|
| App is directly internet-facing (no reverse proxy) | PresetDirectConnection() |
| NGINX/Caddy runs on the same host and proxies to your app | PresetLoopbackReverseProxy() |
| App runs on a VM/private network behind one or more internal proxies | PresetVMReverseProxy() |
| You have a best-effort custom header and want fallback to XFF | PresetPreferredHeaderThenXFFLax("X-Frontend-IP") |
Preset examples:
// Typical VM setup (reverse proxy + private networking)
vmExtractor, err := clientip.New(
clientip.PresetVMReverseProxy(),
)
// Prefer a best-effort header, then fallback to XFF and RemoteAddr
fallbackExtractor, err := clientip.New(
clientip.TrustLoopbackProxy(),
clientip.PresetPreferredHeaderThenXFFLax("X-Frontend-IP"),
)
_ = vmExtractor
_ = fallbackExtractorextractor, err := clientip.New()
if err != nil {
log.Fatal(err)
}
result := extractor.ExtractIP(req)
if result.Valid() {
fmt.Printf("Client IP: %s\n", result.IP)
} else {
fmt.Printf("Failed: %v\n", result.Err)
}cidrs, err := clientip.ParseCIDRs("10.0.0.0/8", "172.16.0.0/12")
if err != nil {
log.Fatal(err)
}
extractor, err := clientip.New(
// min=0 allows requests where proxy headers contain only the client IP
// (trusted RemoteAddr is validated separately).
clientip.TrustedProxies(cidrs, 0, 3),
clientip.Priority(clientip.SourceXForwardedFor, clientip.SourceRemoteAddr),
clientip.XFFStrategy(clientip.RightmostIP),
)
if err != nil {
log.Fatal(err)
}extractor, err := clientip.New(
clientip.TrustPrivateProxyRanges(),
clientip.Priority(
"CF-Connecting-IP",
clientip.SourceXForwardedFor,
clientip.SourceRemoteAddr,
),
)// Strict is default and fails closed on security errors
// (including malformed Forwarded and invalid present source values).
strictExtractor, _ := clientip.New(
clientip.TrustProxyIP("1.1.1.1"),
clientip.Priority("X-Frontend-IP", clientip.SourceXForwardedFor, clientip.SourceRemoteAddr),
clientip.WithSecurityMode(clientip.SecurityModeStrict),
)
// Lax mode allows fallback to lower-priority sources after those errors.
laxExtractor, _ := clientip.New(
clientip.TrustProxyIP("1.1.1.1"),
clientip.Priority("X-Frontend-IP", clientip.SourceXForwardedFor, clientip.SourceRemoteAddr),
clientip.WithSecurityMode(clientip.SecurityModeLax),
)By default, logging is disabled. Use WithLogger to opt in.
WithLogger accepts any implementation of:
type Logger interface {
WarnContext(context.Context, string, ...any)
}This intentionally mirrors slog.Logger.WarnContext, so *slog.Logger
works directly with WithLogger (no adapter needed).
The context passed to logger calls comes from req.Context(), so trace/span IDs
added by middleware remain available in logs.
Structured log attributes are passed as alternating key/value pairs, matching
the style used by slog.
When configured, the extractor emits warning logs for security-significant
conditions such as multiple_headers, malformed_forwarded, chain_too_long,
untrusted_proxy, no_trusted_proxies, too_few_trusted_proxies, and too_many_trusted_proxies.
extractor, err := clientip.New(
clientip.WithLogger(slog.Default()),
)For loggers without context-aware APIs, adapters can simply ignore ctx:
type stdLoggerAdapter struct{ l *log.Logger }
func (a stdLoggerAdapter) WarnContext(_ context.Context, msg string, args ...any) {
a.l.Printf("WARN %s %v", msg, args)
}
extractor, err := clientip.New(
clientip.WithLogger(stdLoggerAdapter{l: log.Default()}),
)Tiny adapters for other popular loggers:
type zapAdapter struct{ l *zap.SugaredLogger }
func (a zapAdapter) WarnContext(_ context.Context, msg string, args ...any) {
a.l.With(args...).Warn(msg)
}type logrusAdapter struct{ l *logrus.Logger }
func (a logrusAdapter) WarnContext(_ context.Context, msg string, args ...any) {
fields := logrus.Fields{}
for i := 0; i+1 < len(args); i += 2 {
key, ok := args[i].(string)
if !ok {
continue
}
fields[key] = args[i+1]
}
a.l.WithFields(fields).Warn(msg)
}type zerologAdapter struct{ l zerolog.Logger }
func (a zerologAdapter) WarnContext(_ context.Context, msg string, args ...any) {
event := a.l.Warn()
for i := 0; i+1 < len(args); i += 2 {
key, ok := args[i].(string)
if !ok {
continue
}
event = event.Interface(key, args[i+1])
}
event.Msg(msg)
}If your stack stores trace metadata in context.Context, enrich the adapter by
extracting that value and appending it to args.
import clientipprom "github.com/abczzz13/clientip/prometheus"
extractor, err := clientip.New(
clientipprom.WithMetrics(),
)import (
clientipprom "github.com/abczzz13/clientip/prometheus"
"github.com/prometheus/client_golang/prometheus"
)
registry := prometheus.NewRegistry()
extractor, err := clientip.New(
clientipprom.WithRegisterer(registry),
)TrustedProxies([]netip.Prefix, min, max)set trusted proxy CIDRs with min/max trusted proxy counts in proxy header chainsTrustedCIDRs(...string)parse CIDR strings in-placeTrustLoopbackProxy()trust loopback upstream proxies (127.0.0.0/8,::1/128)TrustPrivateProxyRanges()trust private upstream proxy ranges (10/8,172.16/12,192.168/16,fc00::/7)TrustLocalProxyDefaults()trust loopback + private proxy rangesTrustProxyIP(string)trust a single upstream proxy IP (exact host prefix)PresetDirectConnection()remote-address only extraction presetPresetLoopbackReverseProxy()loopback reverse-proxy preset (X-Forwarded-For, thenRemoteAddr)PresetVMReverseProxy()VM/private-network reverse-proxy preset (X-Forwarded-For, thenRemoteAddr)PresetPreferredHeaderThenXFFLax(string)preferred-header fallback preset in lax modeMinProxies(int)/MaxProxies(int)set bounds afterTrustedCIDRsAllowPrivateIPs(bool)allow private client IPsMaxChainLength(int)limit proxy chain length fromForwarded/X-Forwarded-For(default 100)XFFStrategy(Strategy)chooseRightmostIP(default) orLeftmostIPPriority(...string)set source order; built-ins:SourceForwarded,SourceXForwardedFor,SourceXRealIP,SourceRemoteAddr(built-in aliases are canonicalized, e.g."Forwarded","X-Forwarded-For","X_Real_IP","Remote-Addr"), with at most one chain header source (SourceForwardedorSourceXForwardedFor) per extractorWithSecurityMode(SecurityMode)chooseSecurityModeStrict(default) orSecurityModeLaxWithLogger(Logger)inject logger implementationWithMetrics(Metrics)inject custom metrics implementation directlyWithDebugInfo(bool)include chain analysis inResult.DebugInfo
Default source order is SourceRemoteAddr.
Any header-based source requires trusted upstream proxy ranges (TrustedCIDRs, TrustedProxies, or one of the trust helpers).
Prometheus adapter options from github.com/abczzz13/clientip/prometheus:
WithMetrics()enable Prometheus metrics with default registererWithRegisterer(prometheus.Registerer)enable Prometheus metrics with custom registerer
Options are applied in order. If multiple metrics options are provided, the last one wins.
Proxy count bounds (min/max) apply to trusted proxies present in Forwarded (from for= values) and X-Forwarded-For.
The immediate proxy (RemoteAddr) is validated for trust separately before either header is trusted.
type Result struct {
IP netip.Addr
Source string // "forwarded", "x_forwarded_for", "x_real_ip", "remote_addr", or normalized custom header
Err error
TrustedProxyCount int
DebugInfo *ChainDebugInfo
}
func (r Result) Valid() boolCustom header names are normalized via NormalizeSourceName (lowercase with underscores).
result := extractor.ExtractIP(req)
if !result.Valid() {
switch {
case errors.Is(result.Err, clientip.ErrMultipleXFFHeaders):
// Possible spoofing attempt
case errors.Is(result.Err, clientip.ErrInvalidForwardedHeader):
// Malformed Forwarded header
case errors.Is(result.Err, clientip.ErrUntrustedProxy):
// Forwarded/XFF came from an untrusted immediate proxy
case errors.Is(result.Err, clientip.ErrNoTrustedProxies):
// No trusted proxies found in the chain
case errors.Is(result.Err, clientip.ErrTooFewTrustedProxies):
// Trusted proxy count is below configured minimum
case errors.Is(result.Err, clientip.ErrTooManyTrustedProxies):
// Trusted proxy count exceeds configured maximum
case errors.Is(result.Err, clientip.ErrInvalidIP):
// Invalid or implausible client IP
case errors.Is(result.Err, clientip.ErrSourceUnavailable):
// Requested source was not present on this request
}
var mh *clientip.MultipleHeadersError
if errors.As(result.Err, &mh) {
// Inspect mh.HeaderCount or mh.RemoteAddr
}
}Typed chain-related errors expose additional context:
ProxyValidationError:Chain,TrustedProxyCount,MinTrustedProxies,MaxTrustedProxiesInvalidIPError:Chain,ExtractedIP,Index,TrustedProxies
- Parses RFC7239
Forwardedheader (for=chain) and rejects malformed values - Rejects multiple
X-Forwarded-Forheaders (spoofing defense) - Requires the immediate proxy (
RemoteAddr) to be trusted before honoringForwardedorX-Forwarded-For(when trusted CIDRs are configured) - Requires trusted proxy CIDRs for any header-based source
- Allows at most one chain-header source (
ForwardedorX-Forwarded-For) per extractor configuration - Enforces trusted proxy count bounds and chain length
- Filters implausible IPs (loopback, multicast, reserved); optional private IP allowlist
- Strict fail-closed behavior is the default (
SecurityModeStrict) for security-significant errors and invalid present source values - Set
WithSecurityMode(SecurityModeLax)to continue fallback after security errors
- O(n) in chain length; extractor is safe for concurrent reuse
prometheus/go.modintentionally does not use a localreplacedirective forgithub.com/abczzz13/clientip.- For local co-development, create an uncommitted workspace with
go work init . ./prometheus. - Validate the adapter as a consumer with
GOWORK=off go -C prometheus test ./.... justuses consumer mode for adapter checks by default; override locally withCLIENTIP_ADAPTER_GOWORK=auto just <target>.- Release in this order: tag root module
vX.Y.Z, bumpprometheus/go.modto that version, then tag adapter moduleprometheus/vX.Y.Z.
See LICENSE.