fix(api-tokens): harden rate limiting and surface list-load errors (#1292)

* fix(api-tokens): scope per-token rate limits to live tokens

Forged or token-shaped Authorization headers no longer mint their own
rate-limit budget. The key generator now grants a per-token budget only to
a real, active token and falls back to per-IP keying for anything else, so
a single source cannot evade the global limiter by rotating fake tokens.
The validated token is memoized on the request, so authentication reuses
it without a second database lookup.

Token validation (format, checksum, lookup, revocation, expiry) is now a
single shared helper used by the HTTP auth middleware, the WebSocket
upgrade handler, and the rate-limit key generator, replacing two
near-identical inline copies that could drift apart. The last-used
timestamp write is throttled so a busy token no longer writes to the
database on every request.

* fix(api-tokens): surface token list-load failures with a retry

A failed load of the API tokens list was swallowed: a server error
rendered the empty "no tokens yet" state with no sign that anything went
wrong. The list now shows an error card with a Retry action and raises a
toast on any non-ok response or network error, matching the create and
revoke flows. Adds a troubleshooting entry for the error.

* test(api-tokens): seed tokens via the shared test helper

The new hardening and WS-scope suites computed sha256 of a raw token
directly, which CodeQL flags as js/insufficient-password-hash (a false
positive: these are 256-bit CSPRNG opaque tokens, not passwords). Route
token creation through the existing apiTokenTestHelper and read the stored
token_hash back from the row, so the suites no longer hash anything
themselves. Also removes the duplicated createToken helpers.

* fix(api-tokens): key the rate limiter by the same credential auth uses

The rate-limit key generator checked the session cookie before the
Authorization bearer, while authMiddleware authenticates bearer-over-cookie
(bearerToken || cookieToken). A request could send a Bearer API token plus a
forged cookie and be keyed by the cookie's (forgeable, rotatable) username,
sidestepping the per-token / per-IP keying the limiter applies to API tokens:
a valid token would lose its own bucket, and a forged token-shaped bearer
would no longer collapse to per-IP.

Reorder the generator to mirror auth: process the bearer first (validate the
API token and key per-token or fall back to per-IP; otherwise decode the JWT
by username/sub), and consult the cookie only when there is no bearer.
Regression tests cover a valid and a forged sen_sk_ bearer, each sent with a
forged cookie.
This commit is contained in:
Anso
2026-06-03 08:18:16 -04:00
committed by GitHub
parent c65c193a59
commit 2435da232b
10 changed files with 552 additions and 52 deletions
+36 -17
View File
@@ -1,10 +1,10 @@
import type { Request } from 'express';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { COOKIE_NAME } from '../helpers/constants';
import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns';
import { looksLikeApiToken } from '../utils/apiTokenFormat';
import { validateApiToken } from '../utils/apiTokenAuth';
// ── Rate Limiting ─────────────────────────────────────────────────────────────
//
@@ -63,32 +63,51 @@ function isNodeProxyRequest(req: Request): boolean {
}
/**
* Hybrid rate limit key: JWT username/sub for authenticated requests
* (per-user budgets), IP otherwise. `jwt.decode()` avoids double-verification;
* `authMiddleware` handles signature checks downstream.
* Hybrid rate limit key: per-token / per-user for authenticated requests, IP
* otherwise. Mirrors authMiddleware's bearer-over-cookie precedence (auth.ts
* uses `bearerToken || cookieToken`) so the limiter keys off the same credential
* auth will use. Checking the cookie first would let a request authenticated by
* a Bearer API token be bucketed under an unrelated (or forged) cookie username,
* sidestepping the per-token / per-IP keying. `jwt.decode()` avoids
* double-verification; `authMiddleware` handles signature checks downstream.
*/
function rateLimitKeyGenerator(req: Request): string {
const cookie = req.cookies?.[COOKIE_NAME];
if (cookie) {
try {
const decoded = jwt.decode(cookie) as { username?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
} catch { /* fall through to IP */ }
}
export function rateLimitKeyGenerator(req: Request): string {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const bearer = auth.slice(7);
// Opaque API tokens key by a non-reversible hash slice of the token
// itself: each token gets its own rate-limit budget without a DB hit on
// the hot path (this runs before authMiddleware).
// Opaque API tokens get a per-token rate-limit budget, but ONLY when the
// bearer resolves to a real, active token. This runs before authMiddleware,
// so keying any token-shaped string by its own hash would let one source
// mint a fresh budget per forged value and fragment the limiter; anything
// that is not a live token therefore falls through to per-IP keying. The
// validated row is memoized on the request so authMiddleware reuses it
// without a second lookup (and a request crossing two limiters reuses it
// here too). Like the jwt.decode branches, a lookup failure degrades to
// per-IP keying rather than throwing out of the key generator.
if (looksLikeApiToken(bearer)) {
const slice = createHash('sha256').update(bearer).digest('hex').slice(0, 16);
return `user:sk:${slice}`;
if (req._apiToken) return `user:sk:${req._apiToken.token_hash.slice(0, 16)}`;
try {
const validation = validateApiToken(bearer);
if (validation.ok) {
// Only ever memoize the row matching this request's bearer;
// authMiddleware trusts req._apiToken without re-checking the hash.
req._apiToken = validation.token;
return `user:sk:${validation.token.token_hash.slice(0, 16)}`;
}
} catch { /* fall through to IP */ }
return ipKeyGenerator(req.ip || 'unknown');
}
try {
const decoded = jwt.decode(bearer) as { username?: string; sub?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
if (decoded?.sub) return `user:${decoded.sub}`;
} catch { /* fall through to cookie / IP */ }
}
const cookie = req.cookies?.[COOKIE_NAME];
if (cookie) {
try {
const decoded = jwt.decode(cookie) as { username?: string } | null;
if (decoded?.username) return `user:${decoded.username}`;
} catch { /* fall through to IP */ }
}
return ipKeyGenerator(req.ip || 'unknown');