787 lines
23 KiB
Go
787 lines
23 KiB
Go
package serve
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
)
|
|
|
|
func TestGenerateToken(t *testing.T) {
|
|
t1 := generateToken()
|
|
t2 := generateToken()
|
|
if t1 == t2 {
|
|
t.Error("generateToken should produce unique values")
|
|
}
|
|
if len(t1) < 32 {
|
|
t.Errorf("token too short: %d bytes", len(t1))
|
|
}
|
|
// Should be base64url-encoded (no +, no /).
|
|
if strings.ContainsAny(t1, "+/") {
|
|
t.Errorf("token contains non-base64url chars: %q", t1)
|
|
}
|
|
}
|
|
|
|
func TestHashPassword(t *testing.T) {
|
|
h, err := HashPassword("test-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(h, "$2a$12$") {
|
|
t.Errorf("unexpected bcrypt prefix: %s", h[:20])
|
|
}
|
|
// Same password should produce a different hash (random salt).
|
|
h2, _ := HashPassword("test-password")
|
|
if h == h2 {
|
|
t.Error("same password should produce different hash")
|
|
}
|
|
}
|
|
|
|
func TestAuthGateModeNone(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{}) // default: authNone
|
|
if ag.Mode() != "none" {
|
|
t.Errorf("mode = %q, want none", ag.Mode())
|
|
}
|
|
if ag.Token() != "" {
|
|
t.Errorf("token = %q, want empty", ag.Token())
|
|
}
|
|
|
|
// In none mode, requests pass through.
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeAuthModeRejectsUnknown(t *testing.T) {
|
|
if _, err := NormalizeAuthMode("tokne"); err == nil {
|
|
t.Fatal("unknown auth mode should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestInvalidAuthModeFailsClosed(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "tokne"})
|
|
if ag.Mode() != "invalid" {
|
|
t.Errorf("mode = %q, want invalid", ag.Mode())
|
|
}
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler should not run for invalid auth mode")
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ── Token mode tests ──
|
|
|
|
func TestTokenModeNoAuthReturns401(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTokenModeValidCookie(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
|
|
req.AddCookie(&http.Cookie{Name: cookieToken, Value: "secret"})
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTokenModeInvalidCookie(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
|
|
req.AddCookie(&http.Cookie{Name: cookieToken, Value: "wrong"})
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTokenModeValidQueryParamRedirects(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
|
|
// Use a handler that records whether auth passed.
|
|
var passed bool
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
passed = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
// Don't follow redirects so we can inspect the 302.
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
resp, err := client.Get(ts.URL + "/?token=secret")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Errorf("status = %d, want 302", resp.StatusCode)
|
|
}
|
|
|
|
// Check that the Set-Cookie header is present.
|
|
setCookie := resp.Header.Get("Set-Cookie")
|
|
if !strings.Contains(setCookie, cookieToken+"=secret") {
|
|
t.Errorf("Set-Cookie missing token: %s", setCookie)
|
|
}
|
|
if !strings.Contains(setCookie, "HttpOnly") {
|
|
t.Errorf("Set-Cookie missing HttpOnly: %s", setCookie)
|
|
}
|
|
if passed {
|
|
t.Error("handler should not have run (redirected first)")
|
|
}
|
|
}
|
|
|
|
func TestTokenModeLoopbackCookieAllowsLocalHTTP(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
resp, err := client.Get(ts.URL + "/?token=secret")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
c := findCookie(resp.Cookies(), cookieToken)
|
|
if c == nil {
|
|
t.Fatal("token cookie missing")
|
|
}
|
|
if c.Secure {
|
|
t.Fatal("loopback HTTP token cookie should stay usable without Secure")
|
|
}
|
|
}
|
|
|
|
func TestTokenModeNonLoopbackCookieAllowsPlainHTTP(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
req, _ := http.NewRequest("GET", ts.URL+"/?token=secret", nil)
|
|
req.Host = "192.0.2.10:8787"
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
c := findCookie(resp.Cookies(), cookieToken)
|
|
if c == nil {
|
|
t.Fatal("token cookie missing")
|
|
}
|
|
if c.Secure {
|
|
t.Fatal("plain HTTP token cookie should stay usable without Secure")
|
|
}
|
|
}
|
|
|
|
func TestTokenModeInvalidQueryParam(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/?token=wrong")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTokenModeAutoGeneratesToken(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token"})
|
|
if ag.Mode() != "token" {
|
|
t.Errorf("mode = %q, want token", ag.Mode())
|
|
}
|
|
if ag.Token() == "" {
|
|
t.Error("token should be auto-generated")
|
|
}
|
|
if len(ag.Token()) < 32 {
|
|
t.Errorf("auto-generated token too short: %d", len(ag.Token()))
|
|
}
|
|
}
|
|
|
|
// ── Password mode tests ──
|
|
|
|
func TestPasswordModeLoginPage(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/login")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("login page status = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeNoSessionRedirects(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
req, _ := http.NewRequest("GET", ts.URL+"/", nil)
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Errorf("redirect status = %d, want 302", resp.StatusCode)
|
|
}
|
|
loc := resp.Header.Get("Location")
|
|
if loc != "/login" {
|
|
t.Errorf("redirect location = %q, want /login", loc)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeAPIWithoutSessionReturns401(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
// Simulate a non-browser (fetch) request by not setting Accept: text/html.
|
|
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("API status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeValidLogin(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
// Don't follow redirects so we can capture the session cookie.
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
resp, err := client.PostForm(ts.URL+"/login", url.Values{"password": {"correct"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Errorf("login status = %d, want 302", resp.StatusCode)
|
|
}
|
|
|
|
// Extract session cookie and verify it works.
|
|
cookies := resp.Cookies()
|
|
var sessionCookie *http.Cookie
|
|
for _, c := range cookies {
|
|
if c.Name == cookieSession {
|
|
sessionCookie = c
|
|
break
|
|
}
|
|
}
|
|
if sessionCookie == nil {
|
|
t.Fatal("no session cookie set after login")
|
|
}
|
|
|
|
// Use the session cookie to access a protected page.
|
|
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
|
|
req.AddCookie(sessionCookie)
|
|
resp2, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusOK {
|
|
t.Errorf("authenticated status = %d, want 200", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeNonLoopbackHTTPLoginCookieIsUsable(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
form := url.Values{"password": {"correct"}}
|
|
req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
|
|
req.Host = "192.0.2.10:8787"
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
sessionCookie := findCookie(resp.Cookies(), cookieSession)
|
|
if sessionCookie == nil {
|
|
t.Fatal("session cookie missing")
|
|
}
|
|
if sessionCookie.Secure {
|
|
t.Fatal("plain HTTP password session cookie should stay usable without Secure")
|
|
}
|
|
|
|
req, _ = http.NewRequest("GET", ts.URL+"/status", nil)
|
|
req.Host = "192.0.2.10:8787"
|
|
req.AddCookie(sessionCookie)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("authenticated non-loopback status = %d, want 200", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeSanitizesRedirectCookie(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
form := url.Values{"password": {"correct"}}
|
|
req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(&http.Cookie{Name: cookieRedirect, Value: "//evil.example/path"})
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Errorf("login status = %d, want 302", resp.StatusCode)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/" {
|
|
t.Fatalf("redirect location = %q, want /", loc)
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeWrongPassword(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.PostForm(ts.URL+"/login", url.Values{"password": {"wrong"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("wrong password status = %d, want 401", resp.StatusCode)
|
|
}
|
|
|
|
// Check that no session cookie was set.
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == cookieSession {
|
|
t.Error("session cookie should not be set on wrong password")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPasswordModeEmptyPassword(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
|
|
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})))
|
|
defer ts.Close()
|
|
|
|
resp, err := http.PostForm(ts.URL+"/login", url.Values{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("empty password status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ── Session HMAC tests ──
|
|
|
|
func TestSignAndVerifySession(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
|
|
tok := ag.signSession()
|
|
if !ag.verifySession(tok) {
|
|
t.Error("valid session should verify")
|
|
}
|
|
}
|
|
|
|
func TestSessionVerifiesAcrossRestartWithSamePasswordHash(t *testing.T) {
|
|
hash := mustHash("test")
|
|
ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
|
|
tok := ag1.signSession()
|
|
|
|
ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
|
|
if !ag2.verifySession(tok) {
|
|
t.Fatal("session signed with a persisted password hash should verify after restart")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejectsDifferentPasswordHash(t *testing.T) {
|
|
ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("first")})
|
|
tok := ag1.signSession()
|
|
|
|
ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("second")})
|
|
if ag2.verifySession(tok) {
|
|
t.Fatal("session should not verify with a different password hash")
|
|
}
|
|
}
|
|
|
|
func TestSessionTampered(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
|
|
tok := ag.signSession()
|
|
// Tamper with the payload (change a character before the dot).
|
|
dot := strings.IndexByte(tok, '.')
|
|
tampered := tok[:dot-1] + string(tok[dot-1]^1) + tok[dot:]
|
|
if ag.verifySession(tampered) {
|
|
t.Error("tampered session should not verify")
|
|
}
|
|
}
|
|
|
|
func TestSessionWrongSignature(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
|
|
tok := ag.signSession()
|
|
dot := strings.LastIndexByte(tok, '.')
|
|
// Flip a hex character in the signature, ensuring it actually changes
|
|
// (the first nibble might already be 0 → "0" + sig[1:] would be a no-op).
|
|
sig := tok[dot+1:]
|
|
target := byte('0')
|
|
if sig[0] == target {
|
|
target = 'f' // guaranteed different
|
|
}
|
|
flipped := tok[:dot+1] + string(target) + sig[1:]
|
|
if ag.verifySession(flipped) {
|
|
t.Error("wrong-signature session should not verify")
|
|
}
|
|
}
|
|
|
|
func TestSessionExpired(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
|
|
// Craft a session with an expiry in the past.
|
|
tok := ag.signSession()
|
|
// The format is: expiry|nonce.sig
|
|
pipe := strings.IndexByte(tok, '|')
|
|
expired := "1000000000" + tok[pipe:] // Unix time in 2001
|
|
if ag.verifySession(expired) {
|
|
t.Error("expired session should not verify")
|
|
}
|
|
}
|
|
|
|
func TestSessionMalformed(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
|
|
|
|
for _, bad := range []string{
|
|
"",
|
|
"no-dot",
|
|
"no-pipe.signature",
|
|
".just-signature",
|
|
"payload.",
|
|
"payload.badhex",
|
|
} {
|
|
if ag.verifySession(bad) {
|
|
t.Errorf("malformed session %q should not verify", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Rate limiter tests ──
|
|
|
|
func TestRateLimiterAllowsFiveThenBlocks(t *testing.T) {
|
|
rl := newRateLimit()
|
|
ip := "192.0.2.1"
|
|
|
|
for i := 0; i < rateLimitMax; i++ {
|
|
if !rl.allow(ip) {
|
|
t.Fatalf("attempt %d should be allowed", i+1)
|
|
}
|
|
}
|
|
if rl.allow(ip) {
|
|
t.Error("6th attempt should be blocked")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterResetsAfterWindow(t *testing.T) {
|
|
rl := newRateLimit()
|
|
ip := "192.0.2.2"
|
|
|
|
// Exhaust the limit.
|
|
for i := 0; i < rateLimitMax; i++ {
|
|
rl.allow(ip)
|
|
}
|
|
if rl.allow(ip) {
|
|
t.Error("should be blocked after exhausting limit")
|
|
}
|
|
|
|
// Manually expire the window.
|
|
rl.mu.Lock()
|
|
w := rl.attempts[ip]
|
|
if w != nil {
|
|
w.start = time.Now().Add(-2 * rateLimitWin)
|
|
}
|
|
rl.mu.Unlock()
|
|
|
|
// Now it should be allowed again.
|
|
if !rl.allow(ip) {
|
|
t.Error("should be allowed after window expires")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterDifferentIPs(t *testing.T) {
|
|
rl := newRateLimit()
|
|
// Exhaust one IP.
|
|
for i := 0; i < rateLimitMax; i++ {
|
|
rl.allow("192.0.2.1")
|
|
}
|
|
// Another IP should still be allowed.
|
|
if !rl.allow("192.0.2.2") {
|
|
t.Error("different IP should not be rate-limited")
|
|
}
|
|
}
|
|
|
|
// ── clientIP tests ──
|
|
|
|
func TestClientIPRemoteAddr(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.42:12345"
|
|
if got := ag.clientIP(req); got != "192.0.2.42" {
|
|
t.Errorf("clientIP = %q, want 192.0.2.42", got)
|
|
}
|
|
}
|
|
|
|
func TestClientIPIgnoresXForwardedForWithoutProxy(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
|
|
if got := ag.clientIP(req); got != "192.0.2.1" {
|
|
t.Errorf("should use RemoteAddr without behind_proxy, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClientIPTrustsXForwardedForWithProxy(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.99:12345"
|
|
req.Header.Set("X-Forwarded-For", "192.0.2.42, 10.0.0.1")
|
|
if got := ag.clientIP(req); got != "192.0.2.42" {
|
|
t.Errorf("clientIP = %q, want 192.0.2.42", got)
|
|
}
|
|
}
|
|
|
|
// ── isTLS tests ──
|
|
|
|
func TestIsTLS(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
if ag.isTLS(req) {
|
|
t.Error("plain request should not be TLS")
|
|
}
|
|
}
|
|
|
|
func TestIsTLSIgnoresForwardedProtoWithoutProxy(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
if ag.isTLS(req) {
|
|
t.Error("should ignore X-Forwarded-Proto without behind_proxy")
|
|
}
|
|
}
|
|
|
|
func TestIsTLSTrustsForwardedProtoWithProxy(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
if !ag.isTLS(req) {
|
|
t.Error("should trust X-Forwarded-Proto with behind_proxy")
|
|
}
|
|
}
|
|
|
|
func TestAuthCookieSecurePolicy(t *testing.T) {
|
|
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
|
|
for _, host := range []string{"localhost:8787", "127.0.0.1:8787", "[::1]:8787"} {
|
|
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
|
|
if ag.authCookieSecure(req) {
|
|
t.Errorf("loopback host %s should allow local HTTP cookies", host)
|
|
}
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", "http://192.0.2.10:8787/", nil)
|
|
if ag.authCookieSecure(req) {
|
|
t.Fatal("plain HTTP cookies should stay usable without Secure")
|
|
}
|
|
|
|
proxy := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
|
|
req, _ = http.NewRequest("GET", "http://example.test/", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
if !proxy.authCookieSecure(req) {
|
|
t.Fatal("trusted forwarded HTTPS should mark cookies Secure")
|
|
}
|
|
}
|
|
|
|
func TestPlainHTTPAuthWarning(t *testing.T) {
|
|
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "none"}, "0.0.0.0:8787"); got != "" {
|
|
t.Fatalf("none auth warning = %q, want empty", got)
|
|
}
|
|
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, "127.0.0.1:8787"); got != "" {
|
|
t.Fatalf("loopback warning = %q, want empty", got)
|
|
}
|
|
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "token"}, "0.0.0.0:8787"); !strings.Contains(got, "non-loopback HTTP") {
|
|
t.Fatalf("non-loopback warning = %q, want HTTP exposure warning", got)
|
|
}
|
|
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, ":8787"); !strings.Contains(got, "non-loopback HTTP") {
|
|
t.Fatalf("wildcard warning = %q, want HTTP exposure warning", got)
|
|
}
|
|
}
|
|
|
|
func TestSafeRedirectTarget(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"/", "/"},
|
|
{"/sessions?id=abc#frag", "/sessions?id=abc"},
|
|
{"", "/"},
|
|
{"https://evil.example/path", "/"},
|
|
{"//evil.example/path", "/"},
|
|
{`/\evil.example/path`, "/"},
|
|
{"/%2f%2fevil.example/path", "/"},
|
|
{"/%5cevil.example/path", "/"},
|
|
{"relative/path", "/"},
|
|
} {
|
|
if got := safeRedirectTarget(tc.in); got != tc.want {
|
|
t.Errorf("safeRedirectTarget(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── helpers ──
|
|
|
|
func mustHash(password string) string {
|
|
h, err := HashPassword(password)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
|
for _, c := range cookies {
|
|
if c.Name == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|