Address code review findings across API, web and deployment
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.
This commit is contained in:
Andrew S Erwin
2026-10-20 00:13:30 -05:00
parent 5eab58f99c
commit 4e421f6ea2
58 changed files with 1419 additions and 333 deletions
+23 -1
View File
@@ -40,7 +40,13 @@ func failure(w http.ResponseWriter, status int, s string) {
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if json.NewDecoder(r.Body).Decode(v) != nil {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
// Review L-8: oversized is 413, not malformed JSON.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
failure(w, 413, "Request body too large (limit 1 MiB)")
return false
}
failure(w, 400, "Invalid JSON")
return false
}
@@ -69,6 +75,9 @@ func HandleSignup(w http.ResponseWriter, r *http.Request) {
failure(w, 400, "Invalid email")
return
}
// Review L-2: a display name of only whitespace is not a name. Trim, then
// fall back to the email local-part when nothing remains.
req.Name = strings.TrimSpace(req.Name)
if len([]rune(req.Name)) > maxSignupNameLen {
failure(w, 400, "Name must be at most 120 characters")
return
@@ -144,6 +153,13 @@ func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
failure(w, 415, "Content-Type must be application/json")
return false
}
return checkOrigin(w, r)
}
// checkOrigin rejects a cross-origin Origin when one is present. Extracted
// from checkAuthRequest so the sign-out endpoint can share the same guard
// without forcing a JSON content type on legacy cookie clients (review M-16).
func checkOrigin(w http.ResponseWriter, r *http.Request) bool {
if origin := r.Header.Get("Origin"); origin != "" {
allowed, err := url.Parse(config.Load().NextAuthURL)
scheme := "http"
@@ -236,6 +252,12 @@ func HandleAuth(w http.ResponseWriter, r *http.Request) {
failure(w, 405, "Use POST to sign out")
return
}
// Review M-16: keep sign-out consistent with the rest of the auth
// surface. A browser cross-site POST is rejected; idempotent calls
// for an already-invalid session still succeed below.
if !checkOrigin(w, r) {
return
}
// Revoke every cookie JWT issued for this user, not just this
// browser's copy: signout means "sign out everywhere".
if su, e := ReadSession(r, config.Load().Secret()); e == nil {
+21 -15
View File
@@ -35,13 +35,13 @@ import (
)
const (
loginIPLimit = 20 // logins per IP per window
loginIPWindow = time.Minute //
signupIPLimit = 10 // signups per IP per window
signupIPWindow = time.Hour //
failureThreshold = 5 // consecutive failures before lockout
failureLockout = 15 * time.Minute
failureWindow = time.Hour // failures older than this are forgotten
loginIPLimit = 20 // logins per IP per window
loginIPWindow = time.Minute //
signupIPLimit = 10 // signups per IP per window
signupIPWindow = time.Hour //
failureThreshold = 5 // consecutive failures before lockout
failureLockout = 15 * time.Minute
failureWindow = time.Hour // failures older than this are forgotten
// limiterMaxEntries is the soft cap per store. Review SEC-03: the failure
// tracker previously never evicted sub-threshold entries (zero until), so
@@ -169,9 +169,6 @@ func ResetRateLimits() {
}
}
// resetRateLimits clears all limiter state. Tests only.
func resetRateLimits() { ResetRateLimits() }
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
@@ -285,14 +282,23 @@ func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
// callers (e.g. public endpoints) can have independent buckets.
func NewLimiterStore() *limiterStore { return newLimiterStore() }
// Limiter wraps a handler with a simple per-IP fixed-window rate limit.
// Uses the direct peer IP (no forwarded headers) and does not exempt
// loopback — public endpoints apply to every peer.
// Limiter wraps a handler with a simple per-client fixed-window rate limit.
// Review H-2: this buckets on EffectiveClientIP (the signed forwarded browser
// identity when the trusted Next server attested one), not the raw direct
// peer. Behind the Next proxy every public request otherwise shared one
// instance-wide bucket, so a single caller could deny the endpoint to all
// users. Loopback is not exempt — public endpoints apply to every peer.
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
return LimiterKeyed(s, limit, window, EffectiveClientIP)
}
// LimiterKeyed is Limiter with a caller-supplied bucket key. It backs the
// second public-share layer (review H-2): one hot bookmark id gets its own
// generous budget in addition to the per-client one.
func LimiterKeyed(s *limiterStore, limit int, window time.Duration, key func(*http.Request) string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if ok, retry := s.allow(ip, limit, window, time.Now()); !ok {
if ok, retry := s.allow(key(r), limit, window, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
+45 -7
View File
@@ -93,8 +93,8 @@ func TestFailureTrackerEviction(t *testing.T) {
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
func TestRateLimitMiddleware(t *testing.T) {
resetRateLimits()
t.Cleanup(resetRateLimits)
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.
@@ -109,7 +109,7 @@ func TestRateLimitMiddleware(t *testing.T) {
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
}
// Signup keeps the loopback exemption for host-local consumers.
resetRateLimits()
ResetRateLimits()
var loopSignup *httptest.ResponseRecorder
for i := 0; i < signupIPLimit*2; i++ {
loopSignup = httptest.NewRecorder()
@@ -118,7 +118,7 @@ func TestRateLimitMiddleware(t *testing.T) {
if loopSignup.Code != 204 {
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
}
resetRateLimits()
ResetRateLimits()
// Non-loopback peers get 429 after the window limit, with Retry-After.
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
@@ -136,7 +136,7 @@ func TestRateLimitMiddleware(t *testing.T) {
}
// Signup limiter counts independently.
resetRateLimits()
ResetRateLimits()
var signupLast *httptest.ResponseRecorder
for i := 0; i < signupIPLimit+1; i++ {
signupLast = httptest.NewRecorder()
@@ -207,10 +207,48 @@ func TestSignedForwardedClientIP(t *testing.T) {
}
}
// 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()
ResetRateLimits()
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
@@ -241,5 +279,5 @@ func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
if code := do("198.51.100.2"); code != 200 {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
resetRateLimits()
ResetRateLimits()
}