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.
87 lines
2.4 KiB
Go
87 lines
2.4 KiB
Go
package httpx_test
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
|
)
|
|
|
|
func TestSignerRoundTripAndTamper(t *testing.T) {
|
|
s, err := httpx.NewSigner([]byte("a-signing-key-0123456789abcdef"))
|
|
if err != nil {
|
|
t.Fatalf("NewSigner: %v", err)
|
|
}
|
|
if _, err := httpx.NewSigner(nil); err == nil {
|
|
t.Error("NewSigner(nil) = nil, want an error (fail closed)")
|
|
}
|
|
|
|
const value = "hello.world/with/slashes"
|
|
tok := s.Sign(value)
|
|
got, ok := s.Unsign(tok)
|
|
if !ok || got != value {
|
|
t.Fatalf("Unsign(Sign(%q)) = %q,%v", value, got, ok)
|
|
}
|
|
if _, ok := s.Unsign(tok + "x"); ok {
|
|
t.Error("Unsign accepted a tampered token")
|
|
}
|
|
if _, ok := s.Unsign("not-a-token"); ok {
|
|
t.Error("Unsign accepted a malformed token")
|
|
}
|
|
|
|
// A different key must not validate the token.
|
|
other, _ := httpx.NewSigner([]byte("another-key-0123456789abcdefgh"))
|
|
if _, ok := other.Unsign(tok); ok {
|
|
t.Error("a token validated under a different key")
|
|
}
|
|
}
|
|
|
|
func TestPreSessionCSRFEnsureAndValid(t *testing.T) {
|
|
p, err := httpx.NewPreSessionCSRF([]byte("a-cookie-secret-0123456789abcdef"))
|
|
if err != nil {
|
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/someguid", nil)
|
|
tok, err := p.Ensure(rec, r)
|
|
if err != nil {
|
|
t.Fatalf("Ensure: %v", err)
|
|
}
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 || cookies[0].Name != httpx.PreSessionCookieName {
|
|
t.Fatalf("Ensure cookies = %v, want one %s", cookies, httpx.PreSessionCookieName)
|
|
}
|
|
if !cookies[0].HttpOnly {
|
|
t.Error("the pre-session CSRF cookie is not HttpOnly")
|
|
}
|
|
|
|
// Double-submit: cookie value must equal the submitted token.
|
|
r2 := httptest.NewRequest("POST", "/someguid?do=auth&action=enroll.begin", nil)
|
|
r2.AddCookie(cookies[0])
|
|
if !p.Valid(r2, tok) {
|
|
t.Error("Valid rejected a matching double-submit token")
|
|
}
|
|
if p.Valid(r2, tok+"x") {
|
|
t.Error("Valid accepted a mismatched token")
|
|
}
|
|
if p.Valid(httptest.NewRequest("POST", "/", nil), tok) {
|
|
t.Error("Valid accepted a token with no cookie")
|
|
}
|
|
if p.Valid(r2, "") {
|
|
t.Error("Valid accepted an empty token")
|
|
}
|
|
|
|
// Ensure reuses a valid cookie rather than rotating it every call.
|
|
rec2 := httptest.NewRecorder()
|
|
tok2, err := p.Ensure(rec2, r2)
|
|
if err != nil {
|
|
t.Fatalf("Ensure(reuse): %v", err)
|
|
}
|
|
if tok2 != tok {
|
|
t.Error("Ensure rotated an already-valid token")
|
|
}
|
|
if len(rec2.Result().Cookies()) != 0 {
|
|
t.Error("Ensure re-issued a cookie for a valid token")
|
|
}
|
|
}
|