Files
OwnCord/Server/auth/ratelimit.go
T
J3vbandClaude Fable 5 03fcb7d518 fix: execute the 2026-08-19 audit fix order (docs refresh + five FRAGILE fixes) (#1396)
* docs(plans): phased remediation plan for the 2026-08-19 audit

Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per
finding group, statuses updated in place as phases land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* test(client): give the renderWindow-breaker test its own timeout (audit F-5)

30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a
loaded runner; the test timed out once under CI-like load and passes in
isolation, so it now carries an explicit 20s budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10)

schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL
(matching 030's rebuild), index inventory rewritten from cumulative migration
state, writer/reader pool split described, default-roles table made a
consistent post-migration snapshot, dbgen preamble updated.

protocol.md: DM chat events documented as sequenced/ring-buffered/replayable
(they are), plugin_broadcast seq flipped to Yes, retry_after claim removed
(no WS error carries it), the five enforced-but-documented-as-None rate
limits added (channel_focus, mark_read, call_decline, chat_command, ping),
E2EE announce/offer budgets corrected incl. the per-target inner cap,
BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/
roles field lists completed, member_join top-level status documented.

api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP
limiter and host:port livekit_url, error-code table now matches emitted codes
(INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap
exemptions listed, identity_public_key documented on PATCH /users/me, plugin
endpoints' plain-text errors + X-Plugin-Runtime header documented, /health
503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply
restart-conflict 409s added.

Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes
(buildReady comment, e2e spec-count comments, logctx stray word, three plan
status headers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16)

Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6
session-cap eviction failure in CreateSession (db/auth_queries.go), and the
channel_focus read-state write failure (service/channel.go) all discarded
their errors with no trace — a brute-force lockout could silently fail to
survive a restart. In-memory behavior is unchanged (warn-and-continue); the
lockout write paths are pinned by tests mirroring OC-0061's load-path test.
The session-cap and read-state sites are log-only additions on seams the
existing suites already exercise on the success path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1)

The block gate ran only at voice_join and voluntary voice_token_refresh, so
a blocked user already in the shared 1:1 DM call kept their session
indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser
now severs the call through the dmVoiceEvictor capability handleCloseDM
already exercises, using a new find-only FindDMChannelIDBetween lookup
(sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group
DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler
tests: shared-DM eviction, no-DM no-op, group-only no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): close the role-reassignment/handshake race (audit F-2)

A role reassignment landing mid-handshake was invisible for the socket's
whole life: both handshake paths resolved permissions from the auth-time
c.user snapshot, revokeUnreadableChannels early-returns for a user not yet
in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a
reconnect replaced the client.

Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and
role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the
resume-fallback path re-reads the role once more after registerNow and runs
the revocation pass when it moved, so the reassignment-vs-registration
orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the
live client immediately before acting, mirroring RefreshChannelVisibility.
Pinned by four tests driving real WS handshakes through the existing race
hooks plus a new pre-register/pre-act hook pair; ws suite green under the
default and deadlock builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* refactor(client): remove the inert replay-dedup machinery (audit F-6)

The server writes auth_ok before the replay burst, so replayDedup — created
on socket-open and cleared when auth_ok is processed — could never be active
for a real replayed frame, and the dispatcher's isReplaying() unread gates
never fired. Their no-op behavior is the correct behavior (a buffer/db
resume has no ready payload, so replayed frames must count as unread), so
the machinery, the gates, and the misleading comments are removed rather
than repaired. The pinning tests injected replay frames in an order a
spec-compliant server never produces; they are replaced by a test pinning
the real contract (frames after auth_ok are dispatched verbatim; duplicate
handling belongs to the stores). Client suite green: 5036/5036.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* docs(plans): mark remediation phases 1-6 done

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

* fix(ws): nolint the context-less revoke call golangci-lint flags

revokeUnreadableChannels takes no context by design (admin HubBroadcaster
interface); annotate the one call site inside a ctx-taking function, matching
the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 03:49:13 +02:00

329 lines
9.7 KiB
Go

package auth
import (
"context"
"log/slog"
"strconv"
"time"
"github.com/owncord/server/syncutil"
)
// entry records individual request timestamps for sliding-window limiting.
type entry struct {
timestamps []time.Time
}
// lockoutEntry records when a lockout expires.
type lockoutEntry struct {
expiresAt time.Time
}
// LockoutPersister is an optional persistence backend for lockout entries.
// When provided, lockouts survive server restarts. The interface uses only
// stdlib types to avoid circular dependencies between packages.
type LockoutPersister interface {
UpsertLockout(ctx context.Context, key string, expiresAt time.Time) error
DeleteLockout(ctx context.Context, key string) error
CleanupExpiredLockouts(ctx context.Context) error
// LoadActiveLockouts returns (keys, expiresAt) slices of equal length.
LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error)
}
// rateLimiterShards is the number of independently locked buckets the key
// space is split across. Must be a power of two (shardFor masks with -1).
const rateLimiterShards = 32
// rateLimiterShard holds one bucket's windows/lockouts maps under its own
// mutex, so contention on one key never serializes unrelated keys.
type rateLimiterShard struct {
mu syncutil.Mutex
windows map[string]*entry
lockouts map[string]*lockoutEntry
}
// RateLimiter is an in-memory, thread-safe sliding-window rate limiter with
// optional IP lockout support. When a LockoutStore is provided, lockout
// entries are persisted so they survive server restarts.
//
// Internally the key space is sharded across 32 buckets (FNV-1a of the key),
// each with its own mutex, so the process-wide limiter is no longer a single
// lock every WS message and HTTP request funnels through.
//
// NOTE (L2): The sliding-window counters and the PartialAuthStore /
// UsedTOTPCodeStore (in totp.go) are process-local. The server must run
// as a single instance. Horizontal scaling requires migrating these
// stores to a shared backend (e.g. Redis).
type RateLimiter struct {
shards [rateLimiterShards]rateLimiterShard
store LockoutPersister // nil = pure in-memory (tests, non-login limiters)
}
// newRateLimiter allocates the per-shard maps shared by both constructors.
func newRateLimiter(store LockoutPersister) *RateLimiter {
rl := &RateLimiter{store: store}
for i := range rl.shards {
rl.shards[i].windows = make(map[string]*entry)
rl.shards[i].lockouts = make(map[string]*lockoutEntry)
}
return rl
}
// NewRateLimiter returns an initialised RateLimiter with no persistence.
func NewRateLimiter() *RateLimiter {
return newRateLimiter(nil)
}
// NewPersistentRateLimiter returns a RateLimiter that persists lockouts via
// the provided store. It loads any active lockouts from the store on creation.
func NewPersistentRateLimiter(store LockoutPersister) *RateLimiter {
rl := newRateLimiter(store)
// Load surviving lockouts from the store. Constructor runs at startup
// with no request in flight, so background context.
if keys, expiresAt, err := store.LoadActiveLockouts(context.Background()); err == nil {
for i, key := range keys {
rl.shardFor(key).lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]}
}
} else {
slog.Warn("ratelimit: failed to load persisted lockouts; starting with none", "err", err)
}
return rl
}
// shardFor maps key to its bucket via FNV-1a (inlined so hashing allocates
// nothing, unlike hash/fnv's digest).
func (r *RateLimiter) shardFor(key string) *rateLimiterShard {
h := uint32(2166136261)
for i := 0; i < len(key); i++ {
h ^= uint32(key[i])
h *= 16777619
}
return &r.shards[h&(rateLimiterShards-1)]
}
// Key builds the canonical "prefix:id" rate-limit key. It exists because the
// hot paths (every WS message, every authenticated request) used to pay for a
// fmt.Sprintf per call; strconv.AppendInt into a pre-sized buffer leaves the
// string itself as the only allocation. Compose multi-part keys by nesting:
// Key(Key("voice_e2ee_offer", userID), channelID).
func Key(prefix string, id int64) string {
b := make([]byte, 0, len(prefix)+21) // ':' + up to 20 digits/sign
b = append(b, prefix...)
b = append(b, ':')
b = strconv.AppendInt(b, id, 10)
return string(b)
}
// Allow reports whether a request from key is permitted given the limit and
// window. It records the current request timestamp only when the request is
// permitted. Returns false when key is locked out or has exceeded limit within
// window.
func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool {
s := r.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
// Lockout takes priority.
if lo, ok := s.lockouts[key]; ok {
if time.Now().Before(lo.expiresAt) {
return false
}
delete(s.lockouts, key)
}
now := time.Now()
cutoff := now.Add(-window)
e, ok := s.windows[key]
if !ok {
e = &entry{}
s.windows[key] = e
}
// Prune timestamps outside the current window.
valid := e.timestamps[:0]
for _, ts := range e.timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
e.timestamps = valid
if len(e.timestamps) >= limit {
return false
}
e.timestamps = append(e.timestamps, now)
return true
}
// Lockout prevents any requests from key for duration regardless of the
// sliding-window counter. When a LockoutStore is configured, the lockout
// is persisted so it survives server restarts. The persist write must land
// once the lockout is decided, so the caller's cancellation is detached
// (WithoutCancel) rather than aborting the write mid-request.
func (r *RateLimiter) Lockout(ctx context.Context, key string, duration time.Duration) {
s := r.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
expiresAt := time.Now().Add(duration)
s.lockouts[key] = &lockoutEntry{expiresAt: expiresAt}
if r.store != nil {
if err := r.store.UpsertLockout(context.WithoutCancel(ctx), key, expiresAt); err != nil {
slog.Warn("ratelimit: failed to persist lockout; it will not survive a restart",
"key", key, "err", err)
}
}
}
// IsLockedOut reports whether key is currently under a lockout.
func (r *RateLimiter) IsLockedOut(key string) bool {
s := r.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
lo, ok := s.lockouts[key]
if !ok {
return false
}
if time.Now().Before(lo.expiresAt) {
return true
}
delete(s.lockouts, key)
return false
}
// Check reports whether a request from key would be permitted given the limit
// and window, WITHOUT recording a new timestamp. Use this for read-only
// rate-limit checks where the caller wants to record (via Allow) only on
// specific outcomes such as verification failures.
func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool {
s := r.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
if lo, ok := s.lockouts[key]; ok {
if time.Now().Before(lo.expiresAt) {
return false
}
delete(s.lockouts, key)
}
cutoff := time.Now().Add(-window)
e, ok := s.windows[key]
if !ok {
return true
}
count := 0
for _, ts := range e.timestamps {
if ts.After(cutoff) {
count++
}
}
return count < limit
}
// Reset clears all rate-limit state (timestamps and lockout) for key.
// Like Lockout, the store delete must complete once decided (WithoutCancel).
func (r *RateLimiter) Reset(ctx context.Context, key string) {
s := r.shardFor(key)
s.mu.Lock()
defer s.mu.Unlock()
delete(s.windows, key)
delete(s.lockouts, key)
if r.store != nil {
if err := r.store.DeleteLockout(context.WithoutCancel(ctx), key); err != nil {
slog.Warn("ratelimit: failed to delete persisted lockout; it may reappear after a restart",
"key", key, "err", err)
}
}
}
// Cleanup evicts stale map entries to prevent unbounded memory growth.
//
// A windows entry is removed when every recorded timestamp is older than
// maxWindow — meaning the entry could not affect any future Allow call that
// uses a window equal to or shorter than maxWindow.
//
// A lockouts entry is removed when its expiry has passed.
//
// Shards are swept one at a time, so the periodic cleanup never stalls the
// whole limiter at once.
//
// Pass defaultCleanupMaxWindow (15 minutes) for normal server operation, or
// a shorter duration in tests.
func (r *RateLimiter) Cleanup(maxWindow time.Duration) {
cutoff := time.Now().Add(-maxWindow)
for i := range r.shards {
s := &r.shards[i]
s.mu.Lock()
for key, e := range s.windows {
allStale := true
for _, ts := range e.timestamps {
if ts.After(cutoff) {
allStale = false
break
}
}
if allStale {
delete(s.windows, key)
}
}
now := time.Now()
for key, lo := range s.lockouts {
if now.After(lo.expiresAt) {
delete(s.lockouts, key)
}
}
s.mu.Unlock()
}
if r.store != nil {
// Runs from the StartCleanup background goroutine — no request ctx.
if err := r.store.CleanupExpiredLockouts(context.Background()); err != nil {
slog.Warn("ratelimit: failed to clean up expired persisted lockouts", "err", err)
}
}
}
// StartCleanup runs Cleanup on a ticker with the given interval until the
// stop channel is closed. It is intended to be called in a goroutine:
//
// stop := make(chan struct{})
// go rl.StartCleanup(5*time.Minute, 15*time.Minute, stop)
//
// Closing stop causes the goroutine to exit promptly.
func (r *RateLimiter) StartCleanup(interval, maxWindow time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.Cleanup(maxWindow)
case <-stop:
return
}
}
}
// Len returns the number of entries currently stored in the windows and
// lockouts maps, summed across all shards. It is primarily useful for
// testing and monitoring.
func (r *RateLimiter) Len() (windows, lockouts int) {
for i := range r.shards {
s := &r.shards[i]
s.mu.Lock()
windows += len(s.windows)
lockouts += len(s.lockouts)
s.mu.Unlock()
}
return windows, lockouts
}