mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
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:
@@ -1,6 +1,5 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
DatabaseService,
|
||||
API_TOKEN_SCOPE_TO_ROLE,
|
||||
@@ -23,7 +22,8 @@ import {
|
||||
MFA_PENDING_TTL_MS,
|
||||
} from '../helpers/constants';
|
||||
import { getCookieOptions } from '../helpers/cookies';
|
||||
import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat';
|
||||
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
||||
import { validateApiToken, touchApiTokenLastUsed, type ApiTokenValidation } from '../utils/apiTokenAuth';
|
||||
|
||||
/**
|
||||
* Authenticate a request via cookie session or Bearer token.
|
||||
@@ -48,30 +48,25 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
try {
|
||||
// Opaque sen_sk_ API tokens: scope-based programmatic access. Routed
|
||||
// before jwt.verify so the JWT path stays focused on session, mfa_pending,
|
||||
// node_proxy, and pilot_tunnel. Steps 1-3 (prefix, length, checksum)
|
||||
// reject malformed/typoed keys without touching SQLite.
|
||||
// node_proxy, and pilot_tunnel. validateApiToken rejects malformed/typoed
|
||||
// keys (prefix, length, checksum) without touching SQLite.
|
||||
if (looksLikeApiToken(token)) {
|
||||
// Uniform 401 message across malformed-checksum, unknown-hash, and
|
||||
// expired/revoked paths so the response body is not a token-existence
|
||||
// oracle. Debug logs still capture the specific reason.
|
||||
if (!verifyApiTokenChecksum(token)) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: checksum');
|
||||
// The rate limiter's key generator runs before this middleware and, for
|
||||
// this same bearer, memoizes the validated row on req._apiToken. Reuse it
|
||||
// when present so the token costs one DB lookup per request, not two;
|
||||
// otherwise validate now (e.g. a path the limiter skipped).
|
||||
const validation: ApiTokenValidation = req._apiToken
|
||||
? { ok: true, token: req._apiToken }
|
||||
: validateApiToken(token);
|
||||
if (!validation.ok) {
|
||||
// Uniform 401 across checksum/unknown/expired/revoked so the response
|
||||
// body is not a token-existence oracle; the debug log keeps the reason.
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected:', validation.reason);
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked');
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired');
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
const apiToken = validation.token;
|
||||
touchApiTokenLastUsed(apiToken);
|
||||
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
|
||||
req.user = {
|
||||
username: creator?.username || `api-token:${apiToken.name}`,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user