package auth import ( "net" "net/http" "strings" ) // ClientIP returns the caller's address for the active-sessions surface (#370). // // Both obvious implementations are wrong, and they're wrong in ways that // matter specifically because this feeds a compromise-detection UI: // // - r.RemoteAddr alone. Minstrel is normally behind a reverse proxy, so // every session would show the proxy's address — noise shaped like data, // hiding the exact thing the operator is looking for. // - Trusting X-Forwarded-For. Any client can set that header, so an // attacker could choose what appears in their victim's session list. // A security surface an attacker can write to is worse than none. // // So the header is trusted only when the request actually arrived from a // proxy. If RemoteAddr is public, the caller reached us directly and its XFF // is attacker-controlled, so it's ignored outright. If RemoteAddr is // private/loopback, XFF is walked from the RIGHT — entries are appended as a // request passes through infrastructure, so the rightmost end is the one our // own proxies wrote — and the first address that isn't itself a proxy range // wins. A client forging XFF can only prepend to the untrusted left end, // which that walk never reaches. // // Known limitation, failing closed on purpose: if the proxy sits on a PUBLIC // address (a separate host, or a CDN in front), RemoteAddr isn't in a proxy // range, so we report the proxy rather than the end user. That's a true fact // about where the request came from, which beats trusting a forgeable header. // // Returns "" when nothing usable can be determined. Callers store that as-is // and the UI renders "unknown" rather than inventing a value. func ClientIP(r *http.Request) string { remote := hostOf(r.RemoteAddr) ip := net.ParseIP(remote) if ip == nil || !isProxyRange(ip) { return remote } if forwarded := forwardedClient(r.Header.Get("X-Forwarded-For")); forwarded != "" { return forwarded } // Some proxies set only X-Real-IP. The trust condition is already // satisfied — we know this request came from a proxy range. if real := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); real != nil { return real.String() } return remote } // hostOf strips the port from a RemoteAddr, tolerating values that have none. func hostOf(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { return strings.TrimSpace(remoteAddr) } return host } // forwardedClient walks an X-Forwarded-For value right-to-left and returns // the first address outside our proxy ranges — see ClientIP for why the // direction matters. Returns "" if the header is absent, malformed, or // contains nothing but proxy addresses. func forwardedClient(header string) string { parts := strings.Split(header, ",") for i := len(parts) - 1; i >= 0; i-- { ip := net.ParseIP(strings.TrimSpace(parts[i])) if ip == nil || isProxyRange(ip) { continue } return ip.String() } return "" } // isProxyRange reports whether ip is an address a reverse proxy would // plausibly occupy in a self-hosted deployment: loopback, RFC1918 / ULA // (both covered by IsPrivate), link-local, or unspecified. // // Deliberately not configurable. These ranges cover proxy-on-same-host and // proxy-on-the-same-docker-network, which is essentially every self-hosted // install, and it works with no setup at all (rule #26). An exotic topology // can motivate a setting when one actually turns up. func isProxyRange(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() }