release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
284 lines
9.8 KiB
Go
284 lines
9.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLimiterStoreWindow(t *testing.T) {
|
|
s := newLimiterStore()
|
|
now := time.Now()
|
|
for i := 0; i < 5; i++ {
|
|
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(time.Duration(i)*time.Second)); !ok {
|
|
t.Fatalf("request %d within limit rejected", i+1)
|
|
}
|
|
}
|
|
// Review ERR-3: the rejection carries the remaining window time.
|
|
ok, retry := s.allow("ip", 5, time.Minute, now.Add(10*time.Second))
|
|
if ok {
|
|
t.Fatal("request over limit accepted")
|
|
}
|
|
if retry <= 0 || retry > time.Minute {
|
|
t.Fatalf("retry-after %v outside the remaining window", retry)
|
|
}
|
|
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(61*time.Second)); !ok {
|
|
t.Fatal("request after window reset rejected")
|
|
}
|
|
if ok, _ := s.allow("other", 5, time.Minute, now); !ok {
|
|
t.Fatal("separate key shares the limit")
|
|
}
|
|
}
|
|
|
|
func TestFailureLockout(t *testing.T) {
|
|
s := newLimiterStore()
|
|
now := time.Now()
|
|
for i := 0; i < failureThreshold-1; i++ {
|
|
s.noteFailure("ip|user", now.Add(time.Duration(i)*time.Second))
|
|
}
|
|
if _, locked := s.locked("ip|user", now.Add(time.Minute)); locked {
|
|
t.Fatal("locked below threshold")
|
|
}
|
|
s.noteFailure("ip|user", now.Add(time.Minute))
|
|
retry, locked := s.locked("ip|user", now.Add(2*time.Minute))
|
|
if !locked || retry <= 0 {
|
|
t.Fatalf("not locked after %d failures: %v %v", failureThreshold, retry, locked)
|
|
}
|
|
s.noteSuccess("ip|user")
|
|
if _, locked := s.locked("ip|user", now.Add(2*time.Minute)); locked {
|
|
t.Fatal("successful login did not clear the lockout")
|
|
}
|
|
if _, locked := s.locked("ip|user", now.Add(failureLockout+time.Hour)); locked {
|
|
t.Fatal("lockout did not expire")
|
|
}
|
|
}
|
|
|
|
// Review SEC-03: sub-threshold failure entries (zero until) must not
|
|
// accumulate without bound when an attacker cycles unique emails.
|
|
func TestFailureTrackerEviction(t *testing.T) {
|
|
s := newLimiterStore()
|
|
now := time.Now()
|
|
// Entries tracked two hours ago are fully expired (failureWindow passed).
|
|
old := now.Add(-2 * time.Hour)
|
|
for i := 0; i < limiterMaxEntries+500; i++ {
|
|
s.noteFailure(fmt.Sprintf("ip|user%d", i), old)
|
|
}
|
|
if len(s.m) > limiterMaxEntries+1 {
|
|
t.Fatalf("live-key burst exceeded the soft cap: %d entries", len(s.m))
|
|
}
|
|
// A later call sweeps the fully expired burst.
|
|
s.noteFailure("ip|fresh", now)
|
|
if len(s.m) != 1 {
|
|
t.Fatalf("expired failure entries not evicted: %d remain", len(s.m))
|
|
}
|
|
// Active lockouts survive eviction from an oversized store.
|
|
s2 := newLimiterStore()
|
|
for i := 0; i < failureThreshold; i++ {
|
|
s2.noteFailure("ip|locked", now.Add(time.Duration(i)*time.Second))
|
|
}
|
|
for i := 0; i < limiterMaxEntries+500; i++ {
|
|
s2.noteFailure(fmt.Sprintf("ip|user%d", i), now)
|
|
}
|
|
if _, locked := s2.locked("ip|locked", now.Add(time.Minute)); !locked {
|
|
t.Fatal("active lockout evicted by the memory sweep")
|
|
}
|
|
}
|
|
|
|
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
|
|
|
|
func TestRateLimitMiddleware(t *testing.T) {
|
|
ResetRateLimits()
|
|
t.Cleanup(ResetRateLimits)
|
|
|
|
// Review SEC-1: loopback is NOT exempt on the login endpoint — same-host
|
|
// deployments must keep throttling for real browser logins.
|
|
loop := httptest.NewRequest("POST", "/api/auth/login", nil)
|
|
loop.RemoteAddr = "127.0.0.1:54321"
|
|
var loopLast *httptest.ResponseRecorder
|
|
for i := 0; i < loginIPLimit+1; i++ {
|
|
loopLast = httptest.NewRecorder()
|
|
RateLimitLogin(dummyHandler)(loopLast, loop)
|
|
}
|
|
if loopLast.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
|
|
}
|
|
// Signup keeps the loopback exemption for host-local consumers.
|
|
ResetRateLimits()
|
|
var loopSignup *httptest.ResponseRecorder
|
|
for i := 0; i < signupIPLimit*2; i++ {
|
|
loopSignup = httptest.NewRecorder()
|
|
RateLimitSignup(dummyHandler)(loopSignup, loop)
|
|
}
|
|
if loopSignup.Code != 204 {
|
|
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
|
|
}
|
|
ResetRateLimits()
|
|
|
|
// Non-loopback peers get 429 after the window limit, with Retry-After.
|
|
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
|
|
remote.RemoteAddr = "203.0.113.7:1234"
|
|
var last *httptest.ResponseRecorder
|
|
for i := 0; i < loginIPLimit+1; i++ {
|
|
last = httptest.NewRecorder()
|
|
RateLimitLogin(dummyHandler)(last, remote)
|
|
}
|
|
if last.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 after limit, got %d", last.Code)
|
|
}
|
|
if last.Header().Get("Retry-After") == "" {
|
|
t.Fatal("429 without Retry-After header")
|
|
}
|
|
|
|
// Signup limiter counts independently.
|
|
ResetRateLimits()
|
|
var signupLast *httptest.ResponseRecorder
|
|
for i := 0; i < signupIPLimit+1; i++ {
|
|
signupLast = httptest.NewRecorder()
|
|
RateLimitSignup(dummyHandler)(signupLast, remote)
|
|
}
|
|
if signupLast.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("signup: expected 429 after limit, got %d", signupLast.Code)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
RateLimitLogin(dummyHandler)(w, remote)
|
|
if w.Code != 204 {
|
|
t.Fatalf("signup exhaustion must not limit login: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Review AUTH-RATE: Go accepts a forwarded client IP only when the trusted
|
|
// Next proxy signed it with the shared secret; forged headers fall back to
|
|
// the direct peer, so one browser cannot evade or poison another's bucket.
|
|
func TestSignedForwardedClientIP(t *testing.T) {
|
|
t.Setenv("NEXTAUTH_SECRET", "")
|
|
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
|
|
ts := time.Now().Unix()
|
|
sign := func(ip string, when int64) (string, string) {
|
|
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
|
|
mac.Write([]byte(ip + "|" + strconv.FormatInt(when, 10)))
|
|
return strconv.FormatInt(when, 10), hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
r := httptest.NewRequest("POST", "/api/auth/login", nil)
|
|
r.RemoteAddr = "203.0.113.9:5555"
|
|
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
|
t.Fatalf("unsigned request: got %s", got)
|
|
}
|
|
hts, sig := sign("198.51.100.7", ts)
|
|
r.Header.Set(headerClientIP, "198.51.100.7")
|
|
r.Header.Set(headerClientTs, hts)
|
|
r.Header.Set(headerClientSig, sig)
|
|
if got := EffectiveClientIP(r); got != "198.51.100.7" {
|
|
t.Fatalf("signed forwarded IP ignored: got %s", got)
|
|
}
|
|
_, badSig := sign("198.51.100.8", ts)
|
|
r.Header.Set(headerClientSig, badSig)
|
|
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
|
t.Fatalf("forged signature accepted: got %s", got)
|
|
}
|
|
goodTs, goodSig := sign("198.51.100.7", ts)
|
|
r.Header.Set(headerClientSig, goodSig)
|
|
r.Header.Set(headerClientTs, goodTs)
|
|
r.Header.Set(headerClientIP, "198.51.100.7; DROP TABLE users")
|
|
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
|
t.Fatalf("non-IP forwarded value accepted: got %s", got)
|
|
}
|
|
// Review SEC-2: a forwarded identity never earns the loopback exemption,
|
|
// even when it attests 127.0.0.1; and stale timestamps fall back to the
|
|
// direct peer.
|
|
loopTs, loopSig := sign("127.0.0.1", ts)
|
|
r.Header.Set(headerClientIP, "127.0.0.1")
|
|
r.Header.Set(headerClientTs, loopTs)
|
|
r.Header.Set(headerClientSig, loopSig)
|
|
if isLoopback(r) {
|
|
t.Fatal("signed loopback forwarded IP must not earn the exemption")
|
|
}
|
|
staleTs, staleSig := sign("198.51.100.7", ts-3600)
|
|
r.Header.Set(headerClientIP, "198.51.100.7")
|
|
r.Header.Set(headerClientTs, staleTs)
|
|
r.Header.Set(headerClientSig, staleSig)
|
|
if got := EffectiveClientIP(r); got != "203.0.113.9" {
|
|
t.Fatalf("stale signature accepted: got %s", got)
|
|
}
|
|
}
|
|
|
|
// Review H-2: public endpoints must bucket on the signed forwarded browser
|
|
// identity too, not the shared Next container peer.
|
|
func TestPublicLimiterUsesForwardedIP(t *testing.T) {
|
|
t.Setenv("NEXTAUTH_SECRET", "")
|
|
t.Setenv("AUTH_SECRET", "public-limiter-forward-secret")
|
|
ts := time.Now().Unix()
|
|
sign := func(ip string) (string, string) {
|
|
mac := hmac.New(sha256.New, []byte("public-limiter-forward-secret"))
|
|
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
|
|
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
handler := Limiter(NewLimiterStore(), 2, time.Minute)(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
do := func(ip string) int {
|
|
r := httptest.NewRequest("GET", "/api/public/bookmarks/x", nil)
|
|
r.RemoteAddr = "10.0.0.5:1234"
|
|
hts, sig := sign(ip)
|
|
r.Header.Set(headerClientIP, ip)
|
|
r.Header.Set(headerClientTs, hts)
|
|
r.Header.Set(headerClientSig, sig)
|
|
w := httptest.NewRecorder()
|
|
handler(w, r)
|
|
return w.Code
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if code := do("198.51.100.1"); code != http.StatusOK {
|
|
t.Fatalf("browser 1 request %d: %d", i+1, code)
|
|
}
|
|
}
|
|
if code := do("198.51.100.1"); code != http.StatusTooManyRequests {
|
|
t.Fatalf("browser 1 over limit: %d", code)
|
|
}
|
|
if code := do("198.51.100.2"); code != http.StatusOK {
|
|
t.Fatalf("browser 2 blocked by browser 1: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
|
|
t.Setenv("NEXTAUTH_SECRET", "")
|
|
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
|
|
ResetRateLimits()
|
|
ts := time.Now().Unix()
|
|
sign := func(ip string) (string, string) {
|
|
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
|
|
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
|
|
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
handler := RateLimitLogin(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
|
do := func(ip string) int {
|
|
r := httptest.NewRequest("POST", "/api/auth/login", nil)
|
|
r.RemoteAddr = "10.0.0.5:1234" // shared proxy peer
|
|
hts, sig := sign(ip)
|
|
r.Header.Set(headerClientIP, ip)
|
|
r.Header.Set(headerClientTs, hts)
|
|
r.Header.Set(headerClientSig, sig)
|
|
w := httptest.NewRecorder()
|
|
handler(w, r)
|
|
return w.Code
|
|
}
|
|
for i := 0; i < loginIPLimit; i++ {
|
|
if code := do("198.51.100.1"); code != 200 {
|
|
t.Fatalf("browser 1 request %d: %d", i+1, code)
|
|
}
|
|
}
|
|
if code := do("198.51.100.1"); code != http.StatusTooManyRequests {
|
|
t.Fatalf("browser 1 over limit: %d", code)
|
|
}
|
|
// An unrelated browser behind the same proxy keeps its own budget.
|
|
if code := do("198.51.100.2"); code != 200 {
|
|
t.Fatalf("browser 2 blocked by browser 1: %d", code)
|
|
}
|
|
ResetRateLimits()
|
|
}
|