Implements D-025 (revised 2026-10-15) and D-043. Setup gate (D-025): - Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback); Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy. - The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404 before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is read before ClientIP SETS it (A6). Proxy scheme (D-043): - httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted proxy's X-Forwarded-Proto. The door carries it on the request context (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session and ceremony cookies, and HSTS all agree (AC-13). - Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed as now dead. Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test SetupRequestAllowed / RequestIsHTTPS.
77 lines
2.8 KiB
Go
77 lines
2.8 KiB
Go
package httpx
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"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 (or a raw TCP peer address)
|
|
// is loopback. It gates the setup-mode check (SPEC-003 §7, D-025) and therefore
|
|
// FAILS CLOSED in the other direction: an empty or unparseable value is NOT
|
|
// loopback.
|
|
func IsLoopbackIP(ip string) bool {
|
|
loopback, known := classifyHost(ip)
|
|
return known && loopback
|
|
}
|
|
|
|
// SetupRequestAllowed reports whether a request may be served while the
|
|
// instance is in setup mode (SPEC-003 §7, D-025 revised 2026-10-15): the TCP
|
|
// PEER (the connection's RemoteAddr) must be loopback AND the request must
|
|
// carry NO `X-Forwarded-For` — i.e. the direct SSH tunnel. Anything else,
|
|
// including a request relayed by a trusted proxy, is refused (the door answers
|
|
// 404 before dispatch).
|
|
//
|
|
// Both tests are required: a proxy on the same host presents a loopback peer,
|
|
// so the peer alone is not enough — the ABSENCE of `X-Forwarded-For` is what
|
|
// proves the request did not traverse a proxy. An empty/unparseable peer fails
|
|
// closed. NB: this must be evaluated on the RAW request, before ClientIP() SETS
|
|
// the header (A6).
|
|
func SetupRequestAllowed(r *http.Request) bool {
|
|
if strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
|
return false
|
|
}
|
|
return IsLoopbackIP(r.RemoteAddr)
|
|
}
|
|
|
|
// 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
|
|
}
|