- 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)
121 lines
3.1 KiB
Go
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), "[]"))
|
|
}
|