Secure client IP extraction for net/http and framework-agnostic request inputs with trusted proxy validation, explicit source modeling, and request-scoped resolver caching.
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 are called out in CHANGELOG.md.
This README tracks the current main branch rather than the latest tagged release.
- Install
- Choose the API
- Common Setups
- Rules To Remember
- Quick Start
- Preferred Resolution And Fallback
- Framework-Agnostic Input
- Presets
- Config
- Low-Level Extraction
- Errors
- Logging
- Prometheus Metrics
- Security Guidance
- Compatibility
- Performance
- Maintainer Notes (Multi-Module)
- License
go get github.com/abczzz13/clientipOptional Prometheus adapter:
go get github.com/abczzz13/clientip/prometheusVersion note: the published adapter module
github.com/abczzz13/clientip/prometheus@v0.0.5depends ongithub.com/abczzz13/clientip v0.0.7. This README documents the currentmainbranch.
Use this as a quick decision guide:
| Need | Use |
|---|---|
| Security-sensitive or audit-oriented result | Resolver.ResolveStrict or Extractor |
| Best-effort operational IP with explicit fallback | Resolver.ResolvePreferred |
Framework integration without *http.Request |
Input with Resolver or Extractor |
Parse RemoteAddr outside extraction |
ParseRemoteAddr |
| Coarse policy branching on error categories | ClassifyError |
Construct an Extractor once and reuse it. Build a Resolver on top when you want strict or preferred request-scoped resolution.
Most integrations start with a preset and only drop to a fully manual Config when the proxy topology is unusual.
Direct app-to-client traffic:
extractor, err := clientip.New(clientip.PresetDirectConnection())Reverse proxy on the same host:
extractor, err := clientip.New(clientip.PresetLoopbackReverseProxy())Reverse proxy on a VM or private network:
extractor, err := clientip.New(clientip.PresetVMReverseProxy())Custom trusted header source:
extractor, err := clientip.New(clientip.Config{
TrustedProxyPrefixes: clientip.LoopbackProxyPrefixes(),
Sources: []clientip.Source{
clientip.HeaderSource("CF-Connecting-IP"),
clientip.SourceRemoteAddr,
},
})Framework request input:
Use Input when the framework does not hand you *http.Request directly. The same extractor and resolver rules still apply.
Resolveris the primary integration-facing API;Extractoris the lower-level strict primitive.- Header-based sources require
TrustedProxyPrefixes. - Prefer a preset first, then tweak
Configonly when needed. - Only configure one proxy-chain source at a time:
SourceForwardedorSourceXForwardedFor. - Preferred fallback is operationally useful, but not suitable for authorization, ACLs, or trust-boundary enforcement.
Input.Headersmust preserve repeated header lines as separate slice entries; merging them breaks duplicate detection and chain parsing semantics.
Use Resolver.ResolveStrict for security-sensitive or audit-oriented decisions.
extractor, err := clientip.New(clientip.PresetLoopbackReverseProxy())
if err != nil {
log.Fatal(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{})
if err != nil {
log.Fatal(err)
}
req := &http.Request{RemoteAddr: "127.0.0.1:12345", Header: make(http.Header)}
req.Header.Set("X-Forwarded-For", "8.8.8.8")
req, resolution := resolver.ResolveStrict(req)
if resolution.Err != nil {
log.Fatal(resolution.Err)
}
fmt.Printf("Client IP: %s from %s\n", resolution.IP, resolution.Source)
if cached, ok := clientip.StrictResolutionFromContext(req.Context()); ok {
fmt.Printf("Cached: %s\n", cached.IP)
}Use Resolver.ResolvePreferred when best-effort client IPs are operationally useful, such as rate limiting, analytics, or request tracing.
extractor, err := clientip.New(clientip.Config{
TrustedProxyPrefixes: clientip.LoopbackProxyPrefixes(),
Sources: []clientip.Source{clientip.SourceXForwardedFor},
})
if err != nil {
log.Fatal(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{
PreferredFallback: clientip.PreferredFallbackRemoteAddr,
})
if err != nil {
log.Fatal(err)
}
req := &http.Request{RemoteAddr: "1.1.1.1:12345", Header: make(http.Header)}
_, resolution := resolver.ResolvePreferred(req)
if resolution.Err != nil {
log.Fatal(resolution.Err)
}
fmt.Printf("Client IP: %s from %s (fallback=%t)\n", resolution.IP, resolution.Source, resolution.FallbackUsed)Important fallback guidance:
- Preferred fallback is explicit and only lives on
Resolver. PreferredFallbackRemoteAddris operationally useful, but it is not equivalent to validated proxy-header extraction.- Preferred resolution is not suitable for authorization, ACLs, or other trust-boundary decisions.
- Fallback observability is result-only in this phase. Inspect
Resolution.FallbackUsed; do not expect a separate fallback metric or log event.
If you want a synthetic fallback value instead of RemoteAddr, set ResolverConfig{PreferredFallback: clientip.PreferredFallbackStaticIP, StaticFallbackIP: ...}. Successful static fallback reports SourceStaticFallback.
Use Input with either Extractor or Resolver when your framework does not expose *http.Request directly.
input := clientip.Input{
Context: ctx,
RemoteAddr: remoteAddr,
Path: path,
Headers: headersProvider,
}
input, resolution := resolver.ResolveInputStrict(input)
if resolution.Err != nil {
// handle error
}
if cached, ok := clientip.StrictResolutionFromContext(input.Context); ok {
_ = cached
}Input.Headers must preserve repeated header lines as separate slice entries. Do not merge duplicate lines into a single comma-joined string.
For fasthttp/Fiber style integrations:
input := clientip.Input{
Context: c.UserContext(),
RemoteAddr: c.Context().RemoteAddr().String(),
Path: c.Path(),
Headers: clientip.HeaderValuesFunc(func(name string) []string {
raw := c.Context().Request.Header.PeekAll(name)
if len(raw) == 0 {
return nil
}
values := make([]string, len(raw))
for i, v := range raw {
values[i] = string(v)
}
return values
}),
}Presets return a flat clientip.Config that you can pass directly to New or tweak before construction.
PresetDirectConnection()usesRemoteAddronly.PresetLoopbackReverseProxy()trusts loopback proxies and prioritizesX-Forwarded-ForbeforeRemoteAddr.PresetVMReverseProxy()trusts loopback and common private-network proxy ranges and prioritizesX-Forwarded-ForbeforeRemoteAddr.
extractor, err := clientip.New(clientip.PresetVMReverseProxy())
if err != nil {
log.Fatal(err)
}If you need to tweak a preset, modify the returned config before calling New:
cfg := clientip.PresetVMReverseProxy()
cfg.Sources = []clientip.Source{clientip.SourceForwarded, clientip.SourceRemoteAddr}
extractor, err := clientip.New(cfg)
if err != nil {
log.Fatal(err)
}Presets configure Config, not ResolverConfig. Preferred resolver fallback stays an explicit resolver-level choice.
Config stays flat in the current API.
Most callers should start from PresetDirectConnection, PresetLoopbackReverseProxy, or PresetVMReverseProxy, then adjust the returned Config if they need custom trust ranges, source order, or observability wiring.
Important fields:
TrustedProxyPrefixes []netip.PrefixMinTrustedProxies intMaxTrustedProxies intAllowPrivateIPs boolAllowedReservedClientPrefixes []netip.PrefixMaxChainLength intChainSelection ChainSelectionDebugInfo boolSources []SourceLogger LoggerMetrics Metrics
Useful helpers:
DefaultConfig()ParseCIDRs(...string)LoopbackProxyPrefixes()PrivateProxyPrefixes()LocalProxyPrefixes()ProxyPrefixesFromAddrs(...netip.Addr)
Built-in extractor sources:
SourceForwardedSourceXForwardedForSourceXRealIPSourceRemoteAddrHeaderSource(name)for custom headers
Resolver-only result source:
SourceStaticFallback
Extractor walks Config.Sources in order. ErrSourceUnavailable allows the next source to run, while security-significant failures remain terminal.
Use Extractor directly when you want strict extraction without request-scoped caching or preferred fallback.
extractor, err := clientip.New(clientip.DefaultConfig())
if err != nil {
log.Fatal(err)
}
extraction, err := extractor.Extract(req)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Client IP: %s from %s\n", extraction.IP, extraction.Source)Framework-agnostic extraction is also available:
extraction, err := extractor.ExtractInput(input)
if err != nil {
log.Fatal(err)
}Typed errors remain the detailed error surface:
_, resolution := resolver.ResolveStrict(req)
if resolution.Err != nil {
switch {
case errors.Is(resolution.Err, clientip.ErrMultipleSingleIPHeaders):
case errors.Is(resolution.Err, clientip.ErrInvalidForwardedHeader):
case errors.Is(resolution.Err, clientip.ErrUntrustedProxy):
case errors.Is(resolution.Err, clientip.ErrNoTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrTooFewTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrTooManyTrustedProxies):
case errors.Is(resolution.Err, clientip.ErrInvalidIP):
case errors.Is(resolution.Err, clientip.ErrSourceUnavailable):
case errors.Is(resolution.Err, clientip.ErrNilRequest):
}
}ClassifyError provides a smaller policy-oriented layer on top of those typed errors:
switch clientip.ClassifyError(resolution.Err) {
case clientip.ResultSuccess:
case clientip.ResultUnavailable:
case clientip.ResultInvalid:
case clientip.ResultUntrusted:
case clientip.ResultMalformed:
case clientip.ResultCanceled:
case clientip.ResultUnknown:
}ResultUnknown covers non-nil errors outside the package's standard extraction and resolution categories.
ErrInvalidForwardedHeader covers malformed RFC7239 syntax, including present-but-empty Forwarded values and empty elements or parameters introduced by stray delimiters. In strict extraction, malformed Forwarded remains terminal and does not fall through to a lower-priority source.
Typed chain-related errors expose additional context:
ProxyValidationError:Chain,TrustedProxyCount,MinTrustedProxies,MaxTrustedProxiesInvalidIPError:Chain,ExtractedIP,Index,TrustedProxiesRemoteAddrError:RemoteAddrChainTooLongError:ChainLength,MaxLength
Logging is disabled by default. Set Config.Logger to opt in.
extractor, err := clientip.New(clientip.Config{
Logger: slog.Default(),
})The logger interface intentionally matches slog.Logger.WarnContext:
type Logger interface {
WarnContext(context.Context, string, ...any)
}The context passed to logger calls comes from req.Context() (Extract) or Input.Context (ExtractInput).
Security event labels passed through Metrics.RecordSecurityEvent(...) are the stable exported clientip.SecurityEvent... constants.
Construct Prometheus metrics explicitly and pass them through Config.Metrics.
This constructor-based wiring is the current tagged-release path for github.com/abczzz13/clientip/prometheus@v0.0.5, which depends on root v0.0.7.
import clientipprom "github.com/abczzz13/clientip/prometheus"
metrics, err := clientipprom.New()
if err != nil {
panic(err)
}
extractor, err := clientip.New(clientip.Config{Metrics: metrics})
if err != nil {
panic(err)
}
resolver, err := clientip.NewResolver(extractor, clientip.ResolverConfig{})
if err != nil {
panic(err)
}With a custom registerer:
registry := prometheus.NewRegistry()
metrics, err := clientipprom.NewWithRegisterer(registry)
if err != nil {
panic(err)
}- Use
ResolveStrictorExtractorfor security-sensitive and audit-oriented behavior. - Use
ResolvePreferredonly when explicit fallback is acceptable for operational reasons. - Do not use preferred fallback for authorization, ACLs, or trust-boundary enforcement.
- Do not include multiple competing header-based sources for security decisions.
- Do not trust broad proxy CIDRs unless they are truly under your control.
- Header-based sources require
TrustedProxyPrefixes. LeftmostUntrustedIPonly makes sense when trusted proxy prefixes are configured.
- Core module (
github.com/abczzz13/clientip) supports Go1.21+. - Optional Prometheus adapter (
github.com/abczzz13/clientip/prometheus) supports Go1.21+; CI validates consumer mode on Go1.21.xand1.26.x. - The published adapter module
github.com/abczzz13/clientip/prometheus@v0.0.5depends ongithub.com/abczzz13/clientip v0.0.7. - Prometheus client dependency in the adapter is pinned to
github.com/prometheus/client_golang v1.21.1.
- Extraction is
O(n)in proxy-chain length. Extractoris safe for concurrent reuse.Resolveradds request-scoped caching on top of a reusable extractor.
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 ./...; this intentionally exercises the latest released root module instead of the unreleased workspace API. 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.