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"- Core module (
github.com/abczzz13/clientip) supports Go1.21+. - Optional Prometheus adapter (
github.com/abczzz13/clientip/prometheus) supports Go1.21+and validates in consumer mode on Go1.21.xand1.26.x. - Prometheus client dependency in the adapter is pinned to
github.com/prometheus/client_golang v1.21.1.
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)
}
ip, err := extractor.ExtractAddr(req)
if err != nil {
fmt.Printf("Failed: %v\n", err)
return
}
fmt.Printf("Client IP: %s\n", ip)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.WithChainSelection(clientip.RightmostUntrustedIP),
)
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),
)You can also construct metrics explicitly with clientipprom.New() or
clientipprom.NewWithRegisterer(...) and pass them via
clientip.WithMetrics(...).
New(opts...) accepts one or more Option builders.
For one-shot extraction without reusing an extractor, use:
-
ExtractWithOptions(req, opts...) -
ExtractAddrWithOptions(req, opts...) -
TrustedProxies([]netip.Prefix, min, max)set trusted proxy CIDRs with min/max trusted proxy counts in proxy header chains -
TrustedCIDRs(...string)parse CIDR strings in-place -
TrustLoopbackProxy()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 ranges -
TrustProxyIP(string)trust a single upstream proxy IP (exact host prefix) -
PresetDirectConnection()remote-address only extraction preset -
PresetLoopbackReverseProxy()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 mode -
MinProxies(int)/MaxProxies(int)set bounds afterTrustedCIDRs -
AllowPrivateIPs(bool)allow private client IPs -
MaxChainLength(int)limit proxy chain length fromForwarded/X-Forwarded-For(default 100) -
WithChainSelection(ChainSelection)chooseRightmostUntrustedIP(default) orLeftmostUntrustedIP -
Priority(...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 extractor -
WithSecurityMode(SecurityMode)chooseSecurityModeStrict(default) orSecurityModeLax -
WithLogger(Logger)inject logger implementation -
WithMetrics(Metrics)inject custom metrics implementation directly -
WithMetricsFactory(func() (Metrics, error))lazily construct metrics after option validation (last metrics option wins) -
WithDebugInfo(bool)include chain analysis inExtraction.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 helpers from github.com/abczzz13/clientip/prometheus:
WithMetrics()install Prometheus metrics on default registererWithRegisterer(prometheus.Registerer)install Prometheus metrics on custom registererNew()/NewWithRegisterer(prometheus.Registerer)for explicit metrics construction
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 Extraction struct {
IP netip.Addr
Source string // "forwarded", "x_forwarded_for", "x_real_ip", "remote_addr", or normalized custom header
TrustedProxyCount int
DebugInfo *ChainDebugInfo
}
func (e *Extractor) Extract(req *http.Request, overrides ...OverrideOptions) (Extraction, error)
func (e *Extractor) ExtractAddr(req *http.Request, overrides ...OverrideOptions) (netip.Addr, error)When Extract returns a non-nil error, the returned Extraction value is
best-effort metadata only (typically Source when available). For chain
diagnostics, inspect typed errors like ProxyValidationError and
InvalidIPError.
Per-call overrides let you temporarily adjust policy for a single extraction:
extraction, err := extractor.Extract(
req,
clientip.OverrideOptions{
SecurityMode: clientip.Set(clientip.SecurityModeLax),
},
)Multiple OverrideOptions values are merged left-to-right; later set values
win. Only policy fields are overrideable (logger and metrics stay fixed per
extractor instance).
Custom header names are normalized via NormalizeSourceName (lowercase with underscores).
_, err := extractor.Extract(req)
if err != nil {
switch {
case errors.Is(err, clientip.ErrMultipleXFFHeaders):
// Possible spoofing attempt
case errors.Is(err, clientip.ErrMultipleSingleIPHeaders):
// Duplicate single-IP header values received
case errors.Is(err, clientip.ErrInvalidForwardedHeader):
// Malformed Forwarded header
case errors.Is(err, clientip.ErrUntrustedProxy):
// Forwarded/XFF came from an untrusted immediate proxy
case errors.Is(err, clientip.ErrNoTrustedProxies):
// No trusted proxies found in the chain
case errors.Is(err, clientip.ErrTooFewTrustedProxies):
// Trusted proxy count is below configured minimum
case errors.Is(err, clientip.ErrTooManyTrustedProxies):
// Trusted proxy count exceeds configured maximum
case errors.Is(err, clientip.ErrInvalidIP):
// Invalid or implausible client IP
case errors.Is(err, clientip.ErrSourceUnavailable):
// Requested source was not present on this request
}
var mh *clientip.MultipleHeadersError
if errors.As(err, &mh) {
// Inspect mh.HeaderName, 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) - Rejects multiple values for single-IP headers (for example repeated
X-Real-IP) - 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
- Do not include multiple competing header-based sources in
Priority(...)for security decisions (for example custom header + chain header fallback). Prefer one canonical trusted header plusSourceRemoteAddrfallback only when required. - Do not enable
SecurityModeLaxfor security-enforcement decisions (ACLs, fraud/risk controls, authz). Use strict mode and fail closed. - Do not trust broad proxy CIDRs unless they are truly under your control. Keep trusted ranges minimal and explicit.
- Do not treat a missing/invalid source as benign in critical paths; monitor and remediate extraction errors.
- O(n) in chain length; extractor is safe for concurrent reuse
Benchmark workflow with just:
# Capture a stable baseline (6 samples by default)
just bench-save before "BenchmarkExtract|BenchmarkChainAnalysis|BenchmarkParseIP"
# Make changes, then capture again
just bench-save after "BenchmarkExtract|BenchmarkChainAnalysis|BenchmarkParseIP"
# Compare with benchstat table output (delta + significance)
just bench-compare-saved before afterYou can compare arbitrary files directly via just bench-compare <before-file> <after-file>.
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 ./.... justand CI validate the adapter in consumer mode by default (GOWORK=off); setCLIENTIP_ADAPTER_GOWORK=autolocally when you intentionally want workspace-mode adapter checks.- Release in this order: tag root module
vX.Y.Z, bumpprometheus/go.modto that version, then tag adapter moduleprometheus/vX.Y.Z.
See LICENSE.