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

47 lines
1.7 KiB
Go

package httpx
import (
"context"
"net/http"
"strings"
)
// secureKey carries the per-request "served over https" decision on the request
// context. The door resolves it ONCE (RequestIsHTTPS) and every cookie writer
// reads it back, so the session cookie's `Secure` flag, the `__Host-` prefix,
// and HSTS all agree on one value (D-043).
type secureKey struct{}
// WithSecure records whether the request is served over https (D-043). The door
// sets it before dispatching; a request that never passed through the door has
// no value and is treated as plain http.
func WithSecure(ctx context.Context, secure bool) context.Context {
return context.WithValue(ctx, secureKey{}, secure)
}
// SecureFromRequest reports whether the request is served over https, as
// resolved by the door (D-043). The zero value is false — plain http — which is
// what keeps the loopback bootstrap (the SSH tunnel) working.
func SecureFromRequest(r *http.Request) bool {
v, _ := r.Context().Value(secureKey{}).(bool)
return v
}
// RequestIsHTTPS reports whether r reached the app over https (D-043): either
// the connection itself is TLS, or a TRUSTED proxy terminated TLS and said so
// via `X-Forwarded-Proto`. A header from an untrusted peer is ignored, so a
// client cannot spoof `Secure` cookies onto a plain-http connection. The
// leftmost token is the original client-facing scheme.
func RequestIsHTTPS(r *http.Request, trusted TrustedProxies) bool {
if r.TLS != nil {
return true
}
if !trusted.Contains(remoteIP(r.RemoteAddr)) {
return false
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}