NextMyDay/internal/core/httpx/clientip.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

121 lines
3.1 KiB
Go

package httpx
import (
"fmt"
"net"
"net/http"
"strings"
)
// TrustedProxies is the parsed set of networks whose X-Forwarded-For is
// honoured (A6). An empty set means "nothing is trusted": the peer address is
// the client.
type TrustedProxies []*net.IPNet
// ParseTrustedProxies parses the `server.trusted_proxies` entries. A bare IP
// ("127.0.0.1") becomes a single-host network (/32 or /128); a CIDR
// ("10.0.0.0/8") is used verbatim. Empty entries are skipped.
func ParseTrustedProxies(entries []string) (TrustedProxies, error) {
var tp TrustedProxies
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if strings.Contains(e, "/") {
_, n, err := net.ParseCIDR(e)
if err != nil {
return nil, fmt.Errorf("httpx: trusted proxy %q is not a valid CIDR: %w", e, err)
}
tp = append(tp, n)
continue
}
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("httpx: trusted proxy %q is not an IP or CIDR", e)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
tp = append(tp, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
return tp, nil
}
// Contains reports whether ip is one of the trusted proxies.
func (t TrustedProxies) Contains(ip net.IP) bool {
if ip == nil {
return false
}
for _, n := range t {
if n.Contains(ip) {
return true
}
}
return false
}
// ClientIP resolves the client address for r using the trusted proxies, and
// SETS the request's X-Forwarded-For to that single value — it never appends to
// a client-supplied chain (A6, SPEC-003 §4).
//
// The rules:
//
// - the immediate peer (RemoteAddr) is NOT trusted → any X-Forwarded-For is
// ignored and the peer is the client (a client cannot spoof past the gate);
// - the peer IS trusted → walk X-Forwarded-For left to right and return the
// first address that is not itself a trusted proxy (the real client); if
// every hop is trusted, the last hop is returned.
//
// The resolved value is written back with Header.Set (replace), never Add.
func ClientIP(r *http.Request, trusted TrustedProxies) string {
peer := remoteIP(r.RemoteAddr)
ip := peer
if trusted.Contains(peer) {
ip = resolveForwarded(r.Header.Get("X-Forwarded-For"), trusted, peer)
}
if ip == nil {
return ""
}
s := ip.String()
r.Header.Set("X-Forwarded-For", s) // SET — never append (A6)
return s
}
// resolveForwarded returns the leftmost non-trusted address in the header,
// falling back to the last hop, then to the peer.
func resolveForwarded(header string, trusted TrustedProxies, peer net.IP) net.IP {
if strings.TrimSpace(header) == "" {
return peer
}
var last net.IP
for _, part := range strings.Split(header, ",") {
ip := net.ParseIP(strings.TrimSpace(part))
if ip == nil {
continue
}
last = ip
if !trusted.Contains(ip) {
return ip // the real client
}
}
if last != nil {
return last
}
return peer
}
// remoteIP extracts the IP from a "host:port" RemoteAddr.
func remoteIP(addr string) net.IP {
if addr == "" {
return nil
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
return net.ParseIP(strings.Trim(strings.TrimSpace(host), "[]"))
}