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"extractor, 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.XFFStrategy(clientip.RightmostIP),
)
if err != nil {
log.Fatal(err)
}extractor, err := clientip.New(
clientip.Priority(
"CF-Connecting-IP",
clientip.SourceForwarded,
clientip.SourceXForwardedFor,
clientip.SourceRemoteAddr,
),
)// Strict is default and fails closed on security errors
// (including malformed Forwarded headers).
strictExtractor, _ := clientip.New(
clientip.WithSecurityMode(clientip.SecurityModeStrict),
)
// Lax mode allows fallback to lower-priority sources after security errors.
laxExtractor, _ := clientip.New(
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-placeMinProxies(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")WithSecurityMode(SecurityMode)chooseSecurityModeStrict(default) orSecurityModeLaxWithLogger(Logger)inject logger implementationWithMetrics(Metrics)inject custom metrics implementation directlyWithDebugInfo(bool)include chain analysis inResult.DebugInfo
Default source order is SourceForwarded, SourceXForwardedFor, SourceXRealIP, SourceRemoteAddr.
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
}
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) - 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), including malformedForwardedheaders - 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.