Secure client IP resolution for net/http and framework-agnostic request inputs with trusted proxy validation, explicit source modeling, and operational fallback.
- Stability
- Install
- Quick Start
- Which API Should I Use?
- Strict And Operational Resolution
- Middleware
- Framework-Agnostic Input
- Common Deployments
- Error Handling
- Presets
- Advanced Proxy Configuration
- Observability
- Security Rules
- Threat Model
- Contributing
Starting with v0.1.0, public APIs are intended to preserve compatibility according to Semantic Versioning.
Before a v1.0.0 release, minor-version releases may still add APIs or refine behavior where SemVer allows.
go get github.com/abczzz13/clientipOptional Prometheus adapter:
go get github.com/abczzz13/clientip/observe/prometheusDirect client-to-app traffic trusts only RemoteAddr:
resolver, err := clientip.New()
if err != nil {
log.Fatal(err)
}
result := resolver.Resolve(req)
if result.Err != nil {
// fail closed for security-sensitive decisions
return
}
fmt.Println(result.IP)Loopback reverse proxy using X-Forwarded-For:
resolver, err := clientip.New(clientip.PresetLoopbackReverseProxy())Custom trusted proxy topology:
resolver, err := clientip.New(
clientip.WithTrustedProxies(prefixes...),
clientip.WithSources(clientip.SourceXForwardedFor, clientip.SourceRemoteAddr),
)Header-based sources require trusted proxy prefixes. clientip.New(clientip.WithSources(clientip.SourceXForwardedFor)) returns an error.
Resolve(req)is the strict API for authorization, ACLs, rate limits, abuse protection, and audit decisions.ResolveOperational(req, fallback)is for best-effort analytics and logging when a fallback value is acceptable.Middleware()stores the strictResultin request context and lets your handler decide whether to reject.ResolveInput(input)is for frameworks that do not expose*http.Requestbut can preserve repeated header-line values.ResolveHeaders(ctx, remoteAddr, headers)is the simplest non-net/httpbridge when you already havehttp.Header.
Use Resolve for security-sensitive decisions. It returns a Result with Err set when the request cannot be safely attributed.
Use ResolveOperational only for best-effort analytics/logging paths where fallback is acceptable:
result := resolver.ResolveOperational(req, clientip.RemoteAddrFallback())
if result.FallbackUsed {
fmt.Println(result.FallbackReason)
}Operational fallback success clears Err and sets FallbackUsed plus FallbackReason. Do not use fallback results for authorization, ACLs, rate-limit identity, or other trust-boundary decisions.
StaticFallback(ip) is for caller-supplied operational defaults. The address is normalized but is not checked against client-IP plausibility rules, so validate it yourself if it must be routable or policy-valid.
Middleware is pass-through. It stores the strict Result in request context and never rejects by itself.
Rejection responses are intentionally application-owned so services can control
status codes, response bodies, headers, logging, and tracing.
handler := resolver.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result, ok := clientip.FromContext(r.Context())
if !ok || result.Err != nil {
http.Error(w, "bad client IP", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(result.IP.String()))
}))Use Input when a framework does not expose *http.Request directly. It exists to preserve repeated header-line semantics: duplicate single-IP headers must be detectable, and chain headers must preserve the order in which repeated header lines arrived. Header providers must therefore return repeated header lines as separate values.
result := resolver.ResolveInput(clientip.Input{
Context: ctx,
RemoteAddr: remoteAddr,
Headers: headersProvider,
})For plain http.Header values:
result := resolver.ResolveHeaders(ctx, remoteAddr, headers)Direct app traffic should use the default resolver. It trusts only the connecting peer in RemoteAddr:
resolver, err := clientip.New()An app behind a same-host reverse proxy can use the loopback preset:
resolver, err := clientip.New(clientip.PresetLoopbackReverseProxy())An app behind an internal VM or private-network proxy can use the VM preset when those private ranges are the actual ingress boundary:
resolver, err := clientip.New(clientip.PresetVMReverseProxy())A CDN-origin service using a single-IP header must trust only the CDN peers that can connect to the origin:
resolver, err := clientip.New(
clientip.WithTrustedProxies(trustedCDNPrefixes...),
clientip.WithSources(clientip.HeaderSource("CF-Connecting-IP"), clientip.SourceRemoteAddr),
)An ALB or reverse proxy that appends X-Forwarded-For should use a trusted-proxy range for the actual proxy-to-app path:
resolver, err := clientip.New(
clientip.WithTrustedProxies(trustedIngressPrefixes...),
clientip.WithSources(clientip.SourceXForwardedFor, clientip.SourceRemoteAddr),
)For simple strict handling, check Result.OK() or Result.Err:
result := resolver.Resolve(req)
if !result.OK() {
switch result.Classify() {
case clientip.ResultUntrusted, clientip.ResultMalformed:
http.Error(w, "bad client IP", http.StatusBadRequest)
default:
http.Error(w, "client IP unavailable", http.StatusServiceUnavailable)
}
return
}Use errors.Is for sentinel categories and errors.As for source-specific diagnostics:
result := resolver.Resolve(req)
if errors.Is(result.Err, clientip.ErrUntrustedProxy) {
// The peer was not in WithTrustedProxies while a header source was present.
}
var proxyErr *clientip.ProxyValidationError
if errors.As(result.Err, &proxyErr) {
log.Printf("source=%s trusted=%d chain=%s", proxyErr.SourceName(), proxyErr.TrustedProxyCount, proxyErr.Chain)
}Generic option presets are available:
PresetDirectConnection()trusts onlyRemoteAddr.PresetLoopbackReverseProxy()trusts loopback proxies and usesX-Forwarded-For, thenRemoteAddr.PresetVMReverseProxy()trusts loopback/private proxy ranges and usesX-Forwarded-For, thenRemoteAddr.
Provider and cloud proxy ranges need application-specific filtering before they are trusted. See Trusted Proxy Configuration for provider range sources, CDN header examples, ALB/X-Forwarded-For guidance, and refresh workflow recommendations.
Use WithObserver for result-level metrics/tracing:
metrics, err := prometheus.New()
if err != nil {
log.Fatal(err)
}
resolver, err := clientip.New(clientip.WithObserver(metrics))Prometheus support lives in the optional github.com/abczzz13/clientip/observe/prometheus adapter module. OpenTelemetry and other adapters can use the same Observer interface without adding dependencies to the core package.
Result.Classify() returns a low-cardinality outcome suitable for metrics labels.
RemoteAddris the only inherently trustworthy source.- Forwarding headers are trusted only when the immediate peer is in
WithTrustedProxies. - The default chain algorithm is rightmost-untrusted before the trusted proxy suffix.
- Do not use operational fallback for security decisions.
- Count-only proxy trust is intentionally unsupported:
WithMinTrustedProxies/WithMaxTrustedProxiesvalidate CIDR-trusted hop counts and do not by themselves make a header source trusted.
See Threat Model for security goals, trust assumptions, non-goals, failure behavior, and privacy notes.
See CONTRIBUTING.md for local setup, test commands, documentation expectations, and pull request guidance.