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.
305 lines
9.4 KiB
Go
305 lines
9.4 KiB
Go
package httpx_test
|
|
|
|
import (
|
|
"context"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|