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

61 lines
2.3 KiB
Go

// Package httpx holds the door's HTTP helpers: the session cookie, the client
// IP resolution (trusted proxies, A6), the security headers, log redaction, and
// the CSRF token (SPEC-003 §6/§8).
//
// It imports only net/http + the standard library — the door owns HTTP; the
// pure routing model (internal/core/routing) must never import this.
package httpx
import "net/http"
// SessionCookieName is the base name of the session cookie (SPEC-003 §6). The
// value is an opaque random id; the server-side session row is authoritative.
const SessionCookieName = "myday_session"
// HostPrefix is the `__Host-` cookie-name prefix (L1s). A browser only accepts
// a `__Host-` cookie when it is Secure · Path=/ · has no Domain attribute, so
// the cookie is pinned to the exact origin — a sibling subdomain cannot shadow
// it (a cookie-fixation / session-shadowing DoS).
const HostPrefix = "__Host-"
// SessionCookieNameFor returns the session cookie's name for the transport:
// `__Host-myday_session` over https (Secure is set — required by the prefix)
// and the bare name otherwise, because a browser REJECTS a `__Host-` cookie
// sent without Secure over plain http (the loopback bootstrap / human test).
//
// The `secure` decision is made PER REQUEST by the door (D-043): TLS, or a
// trusted proxy's `X-Forwarded-Proto: https`. Over the plain-http bootstrap it
// is false, so the bare name is used.
func SessionCookieNameFor(secure bool) string {
if secure {
return HostPrefix + SessionCookieName
}
return SessionCookieName
}
// SetSessionCookie writes the hardened session cookie (SPEC-003 §6):
// HttpOnly · Secure · SameSite=Lax · Path=/. The lifetime is server-side, so
// no Max-Age is set (a browser-session cookie).
func SetSessionCookie(w http.ResponseWriter, name, value string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie expires the session cookie (logout, or an invalid session).
func ClearSessionCookie(w http.ResponseWriter, name string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}