Files
job-search/internal/middleware/real_ip.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
2026-10-15 16:12:56 +01:00

241 lines
6.5 KiB
Go

package middleware
import (
"context"
"net/http"
"net/netip"
"strings"
)
// ========== RealIP - ClientInfo ==========
// ClientInfo describes the client as seen after proxy processing.
type ClientInfo struct {
IP netip.Addr // best-effort real client IP
Peer netip.Addr // immediate TCP peer (after PROXY protocol, if used)
Scheme string // "http" or "https", honouring trusted X-Forwarded-Proto
ViaProxy bool // true when IP came from a trusted forwarding header
}
// Client returns the ClientInfo stored by RealIP. Without RealIP it falls back
// to the connection itself (see peerInfo) and ignores forwarding headers.
func Client(r *http.Request) ClientInfo {
if info, ok := r.Context().Value(clientInfoKey).(ClientInfo); ok {
return info
}
return peerInfo(r)
}
// ClientIP returns the client IP as a string ("" if unknown).
func ClientIP(r *http.Request) string {
ip := Client(r).IP
if !ip.IsValid() {
return ""
}
return ip.String()
}
// IsSecure reports whether the request reached the edge over HTTPS. Without
// RealIP it only reports whether the connection to this server is TLS.
func IsSecure(r *http.Request) bool {
return Client(r).Scheme == "https"
}
// peerInfo builds a ClientInfo from the connection alone: the TCP peer (after
// PROXY protocol, if used) and whether this server terminated TLS.
func peerInfo(r *http.Request) ClientInfo {
peer := addrFromRemote(r.RemoteAddr)
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return ClientInfo{
IP: peer,
Peer: peer,
Scheme: scheme,
}
}
// ========== RealIP ==========
// RealIPOptions configures RealIP.
type RealIPOptions struct {
// Trusted are the proxies whose forwarding headers are believed.
Trusted []netip.Prefix
// Headers are the headers the client IP is read from, in priority order:
// the first of them that the request has and that gives an address is
// used. E.g. X-Forwarded-For, Forwarded, X-Real-IP.
Headers []string
// StripUntrusted removes the forwarding headers from requests that are
// not from a trusted proxy.
StripUntrusted bool
}
type ctxKey int
const clientInfoKey ctxKey = iota
var forwardingHeaders = []string{
"X-Forwarded-For",
"X-Forwarded-Proto",
"X-Forwarded-Host",
"X-Forwarded-Port",
"X-Forwarded-Ssl",
"Forwarded",
"X-Real-IP",
}
// RealIP determines the client IP and scheme. Forwarding headers are only
// honoured when the TCP peer is a trusted proxy; the X-Forwarded-For and
// Forwarded chains are walked right-to-left skipping trusted hops, so a
// client cannot spoof its address by prepending entries.
//
// PROXY protocol is handled at the listener (see server package), so by the
// time this runs r.RemoteAddr already holds the address from the PROXY header.
func RealIP(opts RealIPOptions) func(http.Handler) http.Handler {
trusted := func(addr netip.Addr) bool {
for _, prefix := range opts.Trusted {
if prefix.Contains(addr) {
return true
}
}
return false
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := peerInfo(r)
if info.Peer.IsValid() && trusted(info.Peer) {
for _, name := range opts.Headers {
if ip, ok := clientFromHeader(r.Header, name, trusted); ok {
info.IP = ip
info.ViaProxy = true
break
}
}
if proto := forwardedProto(r.Header); proto == "http" || proto == "https" {
info.Scheme = proto
}
} else if opts.StripUntrusted {
for _, name := range forwardingHeaders {
r.Header.Del(name)
}
}
ctx := context.WithValue(r.Context(), clientInfoKey, info)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func clientFromHeader(
header http.Header, name string, trusted func(netip.Addr) bool,
) (netip.Addr, bool) {
switch http.CanonicalHeaderKey(name) {
case "X-Forwarded-For":
return walkChain(splitList(header.Values("X-Forwarded-For")), trusted)
case "Forwarded":
var chain []string
for _, element := range splitList(header.Values("Forwarded")) {
if value, ok := forwardedParam(element, "for"); ok {
chain = append(chain, value)
} else {
chain = append(chain, "") // element without for= breaks the chain
}
}
return walkChain(chain, trusted)
case "X-Real-Ip":
if value := strings.TrimSpace(header.Get("X-Real-IP")); value != "" {
return parseIPToken(value)
}
}
return netip.Addr{}, false
}
// walkChain returns the right-most address that is not a trusted proxy. If
// every hop is trusted, the left-most is returned. Any unparsable hop
// ("unknown", obfuscated identifiers, garbage) stops the walk.
func walkChain(
chain []string, trusted func(netip.Addr) bool,
) (netip.Addr, bool) {
var last netip.Addr
for i := len(chain) - 1; i >= 0; i-- {
ip, ok := parseIPToken(chain[i])
if !ok {
return netip.Addr{}, false
}
if !trusted(ip) {
return ip, true
}
last = ip
}
return last, last.IsValid()
}
func forwardedProto(header http.Header) string {
if value := header.Get("X-Forwarded-Proto"); value != "" {
first, _, _ := strings.Cut(value, ",")
return strings.ToLower(strings.TrimSpace(first))
}
if elements := splitList(header.Values("Forwarded")); len(elements) > 0 {
if value, ok := forwardedParam(elements[0], "proto"); ok {
return strings.ToLower(value)
}
}
return ""
}
// forwardedParam extracts a parameter from one Forwarded HTTP Extension
// element (RFC 7239).
func forwardedParam(element, key string) (string, bool) {
for _, pair := range strings.Split(element, ";") {
name, value, ok := strings.Cut(strings.TrimSpace(pair), "=")
if ok && strings.EqualFold(name, key) {
return strings.Trim(strings.TrimSpace(value), `"`), true
}
}
return "", false
}
func splitList(values []string) []string {
var out []string
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
}
return out
}
// parseIPToken accepts "1.2.3.4", "1.2.3.4:80", "::1", "[::1]" and "[::1]:80".
func parseIPToken(token string) (netip.Addr, bool) {
token = strings.Trim(strings.TrimSpace(token), `"`)
if addrPort, err := netip.ParseAddrPort(token); err == nil {
return addrPort.Addr().Unmap(), true
}
token = strings.TrimSuffix(strings.TrimPrefix(token, "["), "]")
if addr, err := netip.ParseAddr(token); err == nil {
return addr.Unmap().WithZone(""), true
}
return netip.Addr{}, false
}
func addrFromRemote(remote string) netip.Addr {
if addr, ok := parseIPToken(remote); ok {
return addr
}
return netip.Addr{}
}