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.
126 lines
3.8 KiB
Go
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), "[]"))
|
|
}
|