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.
60 lines
2 KiB
Go
60 lines
2 KiB
Go
package httpx
|
|
|
|
import (
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// IsLoopbackHost reports whether a request Host ("host" or "host:port") names a
|
|
// loopback origin.
|
|
//
|
|
// It gates the passkey nudge (SPEC-006 §8): a passkey registered at
|
|
// `127.0.0.1` binds the Relying Party ID `127.0.0.1` and is UNUSABLE at the
|
|
// public domain (O-13/D-041), so the nudge is suppressed on loopback. An
|
|
// empty/unknown host counts as loopback — fail closed, so an origin we cannot
|
|
// classify never triggers the nudge.
|
|
func IsLoopbackHost(hostport string) bool {
|
|
loopback, known := classifyHost(hostport)
|
|
return !known || loopback
|
|
}
|
|
|
|
// IsLoopbackIP reports whether a RESOLVED client IP is loopback. It gates the
|
|
// per-request setup-mode check (SPEC-003 §7, H2) and therefore FAILS CLOSED in
|
|
// the other direction: an empty or unparseable value is NOT loopback.
|
|
//
|
|
// A bind-time check alone is not enough (D-025): a reverse proxy on 127.0.0.1
|
|
// relaying remote clients makes `/setup` reachable past the bind check, so the
|
|
// door must re-check every request.
|
|
func IsLoopbackIP(ip string) bool {
|
|
loopback, known := classifyHost(ip)
|
|
return known && loopback
|
|
}
|
|
|
|
// classifyHost reports whether hostport ("host", "host:port", or a bare IP) is
|
|
// loopback, and whether it could be classified at all. The two callers above
|
|
// resolve "unknown" in opposite directions on purpose: each is fail-closed for
|
|
// its own decision.
|
|
//
|
|
// "Unknown" is only an empty/blank value: a NAME that is not `localhost` is
|
|
// known — and it is not loopback.
|
|
func classifyHost(hostport string) (loopback, known bool) {
|
|
host := strings.TrimSpace(hostport)
|
|
if host == "" {
|
|
return false, false
|
|
}
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
host = strings.Trim(strings.TrimSpace(host), "[]")
|
|
if host == "" {
|
|
return false, false
|
|
}
|
|
if strings.EqualFold(host, "localhost") || strings.EqualFold(host, "localhost.") {
|
|
return true, true
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return ip.IsLoopback(), true
|
|
}
|
|
// A name we cannot map to loopback: known, and not loopback.
|
|
return false, true
|
|
}
|