NextMyDay/internal/core/httpx/loopback.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

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
}