22bf68f12f
Final-review fix wave over the web UI branch. - sessionKey now derives from API_TOKEN and WEB_PASSWORD with a \x00 separator, so rotating the password logs every browser out too. - uiChapter only clears last_chapter_url when the number actually changes. The form is pre-filled, so a bare tap of Save resubmits the same value; that used to destroy the chapter URL silently while updated_at stayed put, degrading Continue to the series index page. - MANGA_WEB_HOST is now required by the prod override rather than falling back to manga.example.com, matching MANGA_API_HOST. - Comment fixes: static cache rationale, pruneLocked aliasing invariant, and the stale "3 routes" line in CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
6.2 KiB
Go
233 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSessionRoundTrip(t *testing.T) {
|
|
key := sessionKey("token-abc", "pw-abc")
|
|
now := time.Now().UnixMilli()
|
|
value := signSession(key, now+60_000)
|
|
if !verifySession(key, value, now) {
|
|
t.Fatal("verifySession = false for a freshly signed cookie, want true")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejects(t *testing.T) {
|
|
key := sessionKey("token-abc", "pw-abc")
|
|
now := time.Now().UnixMilli()
|
|
valid := signSession(key, now+60_000)
|
|
payload, sig, _ := strings.Cut(valid, ".")
|
|
|
|
cases := []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{"empty", ""},
|
|
{"no separator", payload + sig},
|
|
{"unparseable expiry", "notanumber." + sig},
|
|
{"expired", signSession(key, now-1)},
|
|
{"tampered signature", payload + "." + flipLastChar(sig)},
|
|
{"tampered expiry", "99999999999999." + sig},
|
|
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if verifySession(key, tc.value, now) {
|
|
t.Fatalf("verifySession(%q) = true, want false", tc.value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func flipLastChar(s string) string {
|
|
if s == "" {
|
|
return "x"
|
|
}
|
|
last := s[len(s)-1]
|
|
if last == 'A' {
|
|
return s[:len(s)-1] + "B"
|
|
}
|
|
return s[:len(s)-1] + "A"
|
|
}
|
|
|
|
func TestSessionKeyDependsOnToken(t *testing.T) {
|
|
a := sessionKey("token-a", "pw-abc")
|
|
b := sessionKey("token-b", "pw-abc")
|
|
if string(a) == string(b) {
|
|
t.Fatal("sessionKey collided for different API tokens")
|
|
}
|
|
}
|
|
|
|
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
|
a := sessionKey("token-abc", "pw-a")
|
|
b := sessionKey("token-abc", "pw-b")
|
|
if string(a) == string(b) {
|
|
t.Fatal("sessionKey collided for different web passwords with the same API token")
|
|
}
|
|
}
|
|
|
|
func TestSetSessionCookieAttributes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tls bool
|
|
forwarded string
|
|
wantSecure bool
|
|
}{
|
|
{"plain http dev", false, "", false},
|
|
{"direct tls", true, "", true},
|
|
{"behind https proxy", false, "https", true},
|
|
{"behind http proxy", false, "http", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
if tc.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
if tc.forwarded != "" {
|
|
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
c := cookies[0]
|
|
if c.Name != sessionCookieName {
|
|
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
|
|
}
|
|
if !c.HttpOnly {
|
|
t.Fatal("cookie HttpOnly = false, want true")
|
|
}
|
|
if c.SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite)
|
|
}
|
|
if c.Path != "/" {
|
|
t.Fatalf("cookie Path = %q, want /", c.Path)
|
|
}
|
|
if c.Secure != tc.wantSecure {
|
|
t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure)
|
|
}
|
|
if c.MaxAge != int(sessionTTL/time.Second) {
|
|
t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClearSessionCookie(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
rr := httptest.NewRecorder()
|
|
clearSessionCookie(rr, r)
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
if cookies[0].MaxAge >= 0 {
|
|
t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge)
|
|
}
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xff []string
|
|
want string
|
|
}{
|
|
{"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"},
|
|
{"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"},
|
|
{
|
|
// The client sent "1.2.3.4" itself; Traefik appended the address it
|
|
// actually saw. Only the rightmost entry is trustworthy.
|
|
name: "spoofed left entry is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4, 203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
{
|
|
name: "spoofed separate header line is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4", "203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
r.RemoteAddr = tc.remoteAddr
|
|
for _, v := range tc.xff {
|
|
r.Header.Add("X-Forwarded-For", v)
|
|
}
|
|
if got := clientIP(r); got != tc.want {
|
|
t.Fatalf("clientIP() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
|
l := newLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < loginMaxFailures; i++ {
|
|
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
|
|
}
|
|
l.fail("1.2.3.4", now)
|
|
}
|
|
wait := l.retryAfter("1.2.3.4", now)
|
|
if wait <= 0 {
|
|
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures)
|
|
}
|
|
if wait > loginWindow {
|
|
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
|
l := newLoginLimiter()
|
|
start := time.Now()
|
|
for i := 0; i < loginMaxFailures; i++ {
|
|
l.fail("1.2.3.4", start)
|
|
}
|
|
if l.retryAfter("1.2.3.4", start) == 0 {
|
|
t.Fatal("expected block immediately after the failures")
|
|
}
|
|
later := start.Add(loginWindow + time.Second)
|
|
if wait := l.retryAfter("1.2.3.4", later); wait != 0 {
|
|
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
|
l := newLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < loginMaxFailures; i++ {
|
|
l.fail("1.2.3.4", now)
|
|
}
|
|
l.reset("1.2.3.4")
|
|
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterIsPerIP(t *testing.T) {
|
|
l := newLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < loginMaxFailures; i++ {
|
|
l.fail("1.2.3.4", now)
|
|
}
|
|
if wait := l.retryAfter("5.6.7.8", now); wait != 0 {
|
|
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
|
}
|
|
}
|