NextMyDay/internal/core/httpx/loopback.go
Claus Lohmar f0927fb0f0 feat(server): gate /setup by the direct tunnel and trust the proxy scheme
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.
2026-10-15 05:40:36 +00:00

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
}