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

57 lines
2.1 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).
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,
})
}