NextMyDay/internal/core/httpx/clientip.go
Claus Lohmar 03a9db03dc fix(security): harden httpx — XFF hops, __Host- cookie, isolation headers
REVIEW-001 (M0) perimeter findings:

- H1: resolveForwarded walked X-Forwarded-For left→right and returned the
  leftmost untrusted entry. An append-style proxy (nginx's default) appends
  the peer it saw to the RIGHT of whatever the client sent, so the leftmost
  entry is attacker-controlled — a fresh rate-limit bucket per request (A3)
  and a forged client_ip in the audit trail (A11/D-031). Walk right→left and
  return the first untrusted entry, falling back to the peer.
- L1s: the session cookie now carries the `__Host-` prefix when it is Secure
  (https), so it is pinned to the exact origin and cannot be shadowed from a
  sibling subdomain. Conditional on purpose: a browser rejects a `__Host-`
  cookie without Secure, which would break the http loopback bootstrap.
- L4s: add Permissions-Policy, Cross-Origin-Opener-Policy and
  Cross-Origin-Resource-Policy.
- Add httpx.IsLoopbackIP: a RESOLVED client IP test that fails closed on an
  empty/unparseable value (the setup-mode gate, H2), sharing a classifier
  with IsLoopbackHost whose "unknown" direction is the opposite, each
  fail-closed for its own decision.

Tests updated: the unit test encoding the vulnerable leftmost expectation is
rewritten, plus right→left/forged-entry/host-classification coverage.
2026-10-14 11:29:52 +00:00

126 lines
3.8 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 RIGHT to LEFT and return the
// first address that is not itself a trusted proxy (the real client). Each
// trusted hop appended the peer it saw to the right, so the rightmost
// untrusted entry is the one closest to us that a client could not have
// forged (H1); if every hop is trusted, the peer 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 RIGHTMOST address in the header that is not a
// trusted proxy, falling back to the peer.
//
// Walking right→left is the defence (H1): an append-style proxy (nginx's
// default) appends the peer it saw to the RIGHT of whatever the client sent, so
// the leftmost entries are attacker-controlled. The rightmost untrusted entry
// is the closest hop we can trust — it is the first address not appended by a
// trusted proxy. Returning the leftmost entry (the old behaviour) let a client
// forge a fresh rate-limit bucket per request (A3) and a forged client_ip in
// the audit trail (A11/D-031).
func resolveForwarded(header string, trusted TrustedProxies, peer net.IP) net.IP {
parts := strings.Split(header, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !trusted.Contains(ip) {
return ip // the closest untrusted hop — the real client
}
}
// Every hop (or the whole header) is trusted or unparseable: the peer is
// the only address we can vouch for.
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), "[]"))
}