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.
389 lines
12 KiB
Go
389 lines
12 KiB
Go
package httpx_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
|
)
|
|
|
|
func TestSetAndClearSessionCookie(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
httpx.SetSessionCookie(rec, httpx.SessionCookieName, "abc123", true)
|
|
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
c := cookies[0]
|
|
if c.Name != httpx.SessionCookieName || c.Value != "abc123" {
|
|
t.Errorf("cookie = %s=%s", c.Name, c.Value)
|
|
}
|
|
if !c.HttpOnly {
|
|
t.Error("cookie is not HttpOnly")
|
|
}
|
|
if !c.Secure {
|
|
t.Error("cookie is not Secure")
|
|
}
|
|
if c.SameSite != http.SameSiteLaxMode {
|
|
t.Errorf("SameSite = %v, want Lax", c.SameSite)
|
|
}
|
|
if c.Path != "/" {
|
|
t.Errorf("Path = %q, want /", c.Path)
|
|
}
|
|
|
|
// Clearing expires it.
|
|
rec2 := httptest.NewRecorder()
|
|
httpx.ClearSessionCookie(rec2, httpx.SessionCookieName, true)
|
|
if got := rec2.Result().Cookies()[0].MaxAge; got >= 0 {
|
|
t.Errorf("cleared cookie MaxAge = %d, want negative", got)
|
|
}
|
|
}
|
|
|
|
func TestParseTrustedProxies(t *testing.T) {
|
|
tp, err := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", ""})
|
|
if err != nil {
|
|
t.Fatalf("ParseTrustedProxies: %v", err)
|
|
}
|
|
if !tp.Contains(parseIP(t, "127.0.0.1")) {
|
|
t.Error("127.0.0.1 not trusted")
|
|
}
|
|
if !tp.Contains(parseIP(t, "10.1.2.3")) {
|
|
t.Error("10.1.2.3 not covered by 10.0.0.0/8")
|
|
}
|
|
if tp.Contains(parseIP(t, "8.8.8.8")) {
|
|
t.Error("8.8.8.8 reported trusted")
|
|
}
|
|
|
|
if _, err := httpx.ParseTrustedProxies([]string{"not-an-ip"}); err == nil {
|
|
t.Error("ParseTrustedProxies(bad) = nil, want error")
|
|
}
|
|
}
|
|
|
|
// TestRequestIsHTTPS pins D-043: https is decided by the transport — a direct
|
|
// TLS connection, or a TRUSTED proxy's X-Forwarded-Proto. An untrusted peer's
|
|
// header is ignored.
|
|
func TestRequestIsHTTPS(t *testing.T) {
|
|
tp, _ := httpx.ParseTrustedProxies([]string{"10.0.0.5"})
|
|
newReq := func(peer string, useTLS bool, xfp string) *http.Request {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = peer
|
|
if useTLS {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
if xfp != "" {
|
|
r.Header.Set("X-Forwarded-Proto", xfp)
|
|
}
|
|
return r
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
req *http.Request
|
|
want bool
|
|
}{
|
|
{"direct TLS", newReq("203.0.113.9:4444", true, ""), true},
|
|
{"trusted proxy, XFP https", newReq("10.0.0.5:4444", false, "https"), true},
|
|
{"trusted proxy, XFP http", newReq("10.0.0.5:4444", false, "http"), false},
|
|
{"trusted proxy, no XFP", newReq("10.0.0.5:4444", false, ""), false},
|
|
{"trusted proxy, XFP list", newReq("10.0.0.5:4444", false, "https, http"), true},
|
|
{"untrusted peer spoofs XFP https", newReq("203.0.113.9:4444", false, "https"), false},
|
|
{"loopback peer, no XFP", newReq("127.0.0.1:4444", false, ""), false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := httpx.RequestIsHTTPS(tc.req, tp); got != tc.want {
|
|
t.Errorf("RequestIsHTTPS = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSecureFromRequestDefaultsFalse pins the transport-neutral default: a
|
|
// request that never passed through the door is plain http, so cookie writers
|
|
// fall back to the non-Secure (bare) behaviour.
|
|
func TestSecureFromRequestDefaultsFalse(t *testing.T) {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
if httpx.SecureFromRequest(r) {
|
|
t.Error("SecureFromRequest = true with no door-set value, want false")
|
|
}
|
|
ctx := httpx.WithSecure(r.Context(), true)
|
|
if !httpx.SecureFromRequest(r.WithContext(ctx)) {
|
|
t.Error("SecureFromRequest did not read the door-set value")
|
|
}
|
|
}
|
|
|
|
// TestSetupRequestAllowed pins D-025 (revised): only a loopback TCP peer with
|
|
// NO X-Forwarded-For is the direct tunnel; everything else — including a
|
|
// loopback peer carrying XFF, or an unparseable peer — fails closed.
|
|
func TestSetupRequestAllowed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
peer string
|
|
xff string
|
|
want bool
|
|
}{
|
|
{"loopback peer, no XFF", "127.0.0.1:52345", "", true},
|
|
{"loopback peer with XFF", "127.0.0.1:52345", "203.0.113.9", false},
|
|
{"remote peer", "203.0.113.9:4444", "", false},
|
|
{"remote peer with XFF", "203.0.113.9:4444", "198.51.100.7", false},
|
|
{"empty peer", "", "", false},
|
|
{"unparseable peer", "not-an-address", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest("GET", "/setup", nil)
|
|
r.RemoteAddr = tc.peer
|
|
if tc.xff != "" {
|
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
|
}
|
|
if got := httpx.SetupRequestAllowed(r); got != tc.want {
|
|
t.Errorf("SetupRequestAllowed = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) {
|
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = "203.0.113.9:5555"
|
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
|
|
|
|
got := httpx.ClientIP(r, tp)
|
|
if got != "203.0.113.9" {
|
|
t.Errorf("ClientIP = %q, want the peer (XFF from an untrusted peer must be ignored)", got)
|
|
}
|
|
// SET, never append.
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "203.0.113.9" {
|
|
t.Errorf("X-Forwarded-For = %q, want it SET to %q", xff, "203.0.113.9")
|
|
}
|
|
}
|
|
|
|
func TestClientIPTrustedPeerResolvesRightmostUntrusted(t *testing.T) {
|
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = "127.0.0.1:5555"
|
|
// An append-style proxy (nginx default) appends the peer it SAW to the
|
|
// right of whatever the client sent. Here a client forged "1.2.3.4" and the
|
|
// proxy appended the real client "198.51.100.7" (H1).
|
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.7, 10.0.0.5")
|
|
|
|
got := httpx.ClientIP(r, tp)
|
|
if got != "198.51.100.7" {
|
|
t.Errorf("ClientIP = %q, want the rightmost untrusted 198.51.100.7 (H1: the leftmost entry is forgeable)", got)
|
|
}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "198.51.100.7" {
|
|
t.Errorf("X-Forwarded-For = %q, want it SET (replaced), not appended", xff)
|
|
}
|
|
}
|
|
|
|
// TestClientIPIgnoresForgedLeftmostEntry is the H1 regression: the leftmost
|
|
// entry must never win, or an append-style proxy gives the client a fresh
|
|
// rate-limit bucket and a forged audit client_ip (A3/A11).
|
|
func TestClientIPIgnoresForgedLeftmostEntry(t *testing.T) {
|
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = "127.0.0.1:5555"
|
|
r.Header.Set("X-Forwarded-For", "9.9.9.9, 198.51.100.7")
|
|
|
|
if got := httpx.ClientIP(r, tp); got != "198.51.100.7" {
|
|
t.Errorf("ClientIP = %q, want 198.51.100.7 (the forged 9.9.9.9 must be ignored, H1)", got)
|
|
}
|
|
}
|
|
|
|
func TestClientIPAllHopsTrusted(t *testing.T) {
|
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = "127.0.0.1:5555"
|
|
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
|
|
|
|
// Every hop is trusted: the peer is the only address we can vouch for.
|
|
if got := httpx.ClientIP(r, tp); got != "127.0.0.1" {
|
|
t.Errorf("ClientIP = %q, want the peer 127.0.0.1 when every hop is trusted", got)
|
|
}
|
|
}
|
|
|
|
// TestIsLoopbackHost pins the nudge's origin predicate (SPEC-006 §8): a NAME
|
|
// is not loopback, and only an empty/unknown host fails closed to loopback.
|
|
func TestIsLoopbackHost(t *testing.T) {
|
|
cases := []struct {
|
|
host string
|
|
want bool
|
|
}{
|
|
{"127.0.0.1", true},
|
|
{"127.0.0.1:8080", true},
|
|
{"localhost", true},
|
|
{"localhost:8080", true},
|
|
{"[::1]:8080", true},
|
|
{"example.com", false},
|
|
{"myday.example.com:443", false},
|
|
{"203.0.113.9", false},
|
|
{"", true}, // unknown → suppress the nudge (fail closed)
|
|
}
|
|
for _, tc := range cases {
|
|
if got := httpx.IsLoopbackHost(tc.host); got != tc.want {
|
|
t.Errorf("IsLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsLoopbackIPFailsClosed pins the setup-mode gate's predicate (H2): an
|
|
// empty or unparseable resolved client IP must NOT count as loopback.
|
|
func TestIsLoopbackIPFailsClosed(t *testing.T) {
|
|
cases := []struct {
|
|
ip string
|
|
want bool
|
|
}{
|
|
{"127.0.0.1", true},
|
|
{"127.0.0.5", true},
|
|
{"::1", true},
|
|
{"localhost", true},
|
|
{"[::1]", true},
|
|
{"203.0.113.9", false},
|
|
{"10.0.0.5", false},
|
|
{"", false}, // unparseable: fail closed
|
|
{"not-an-ip", false},
|
|
{"0.0.0.0", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := httpx.IsLoopbackIP(tc.ip); got != tc.want {
|
|
t.Errorf("IsLoopbackIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionCookieNameForPrefix pins L1s: the `__Host-` prefix is used exactly
|
|
// when the cookie is Secure (a browser rejects a `__Host-` cookie over http).
|
|
func TestSessionCookieNameForPrefix(t *testing.T) {
|
|
if got := httpx.SessionCookieNameFor(true); got != "__Host-myday_session" {
|
|
t.Errorf("SessionCookieNameFor(true) = %q, want the __Host- prefix", got)
|
|
}
|
|
if got := httpx.SessionCookieNameFor(false); got != httpx.SessionCookieName {
|
|
t.Errorf("SessionCookieNameFor(false) = %q, want the bare name", got)
|
|
}
|
|
}
|
|
|
|
func TestRedactPath(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
mustGone string
|
|
}{
|
|
{"/setup/token/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
|
{"/access/reset/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
|
{"/x/token=SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := httpx.RedactPath(tc.in)
|
|
if strings.Contains(got, tc.mustGone) {
|
|
t.Errorf("RedactPath(%q) = %q, still contains the credential", tc.in, got)
|
|
}
|
|
if !strings.Contains(got, httpx.Redacted) {
|
|
t.Errorf("RedactPath(%q) = %q, want a %s marker", tc.in, got, httpx.Redacted)
|
|
}
|
|
}
|
|
|
|
// The GUID is public and must survive verbatim (D-001).
|
|
const guid = "AbCdEf0123456789"
|
|
if got := httpx.RedactPath("/" + guid + "/home"); got != "/"+guid+"/home" {
|
|
t.Errorf("RedactPath preserved a GUID: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRedactQuery(t *testing.T) {
|
|
got := httpx.RedactQuery("token=SECRET&view=open")
|
|
if strings.Contains(got, "SECRET") {
|
|
t.Errorf("RedactQuery = %q, still contains the token", got)
|
|
}
|
|
if !strings.Contains(got, "view=open") {
|
|
t.Errorf("RedactQuery = %q, want the non-sensitive param kept", got)
|
|
}
|
|
}
|
|
|
|
func TestSetSecurityHeaders(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
httpx.SetSecurityHeaders(rec, false)
|
|
h := rec.Header()
|
|
if h.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Error("missing X-Content-Type-Options: nosniff")
|
|
}
|
|
if h.Get("Referrer-Policy") != "no-referrer" {
|
|
t.Error("missing Referrer-Policy")
|
|
}
|
|
if !strings.Contains(h.Get("Content-Security-Policy"), "default-src 'self'") {
|
|
t.Errorf("CSP = %q", h.Get("Content-Security-Policy"))
|
|
}
|
|
// L4s: the isolation trio.
|
|
if got := h.Get("Permissions-Policy"); !strings.Contains(got, "geolocation=()") {
|
|
t.Errorf("Permissions-Policy = %q, want the feature allowlist", got)
|
|
}
|
|
if got := h.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
|
t.Errorf("Cross-Origin-Opener-Policy = %q, want same-origin", got)
|
|
}
|
|
if got := h.Get("Cross-Origin-Resource-Policy"); got != "same-origin" {
|
|
t.Errorf("Cross-Origin-Resource-Policy = %q, want same-origin", got)
|
|
}
|
|
if h.Get("Strict-Transport-Security") != "" {
|
|
t.Error("HSTS set for an insecure request")
|
|
}
|
|
|
|
rec2 := httptest.NewRecorder()
|
|
httpx.SetSecurityHeaders(rec2, true)
|
|
if rec2.Header().Get("Strict-Transport-Security") == "" {
|
|
t.Error("HSTS missing for a secure request")
|
|
}
|
|
}
|
|
|
|
func TestCSRF(t *testing.T) {
|
|
csrf, err := httpx.NewCSRF([]byte("a-test-csrf-key-0123456789abcdef"))
|
|
if err != nil {
|
|
t.Fatalf("NewCSRF: %v", err)
|
|
}
|
|
|
|
tok := csrf.Token("session-1")
|
|
if tok == "" {
|
|
t.Fatal("Token returned empty")
|
|
}
|
|
if !csrf.Valid("session-1", tok) {
|
|
t.Error("Valid rejected the correct token")
|
|
}
|
|
if csrf.Valid("session-2", tok) {
|
|
t.Error("Valid accepted a token bound to another session")
|
|
}
|
|
if csrf.Valid("session-1", "") {
|
|
t.Error("Valid accepted an empty token")
|
|
}
|
|
if csrf.Valid("", tok) {
|
|
t.Error("Valid accepted an empty session id")
|
|
}
|
|
if csrf.Valid("session-1", tok+"x") {
|
|
t.Error("Valid accepted a tampered token")
|
|
}
|
|
|
|
if _, err := httpx.NewCSRF(nil); err == nil {
|
|
t.Error("NewCSRF(nil) = nil, want an error (fail closed)")
|
|
}
|
|
}
|
|
|
|
func TestTokenContext(t *testing.T) {
|
|
ctx := httpx.WithToken(context.Background(), "tok-9")
|
|
if got := httpx.TokenFrom(ctx); got != "tok-9" {
|
|
t.Errorf("TokenFrom = %q, want tok-9", got)
|
|
}
|
|
if got := httpx.TokenFrom(context.Background()); got != "" {
|
|
t.Errorf("TokenFrom(background) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func parseIP(t *testing.T, s string) (ip net.IP) {
|
|
t.Helper()
|
|
ip = net.ParseIP(s)
|
|
if ip == nil {
|
|
t.Fatalf("ParseIP(%q) = nil", s)
|
|
}
|
|
return ip
|
|
}
|