mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +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:
@@ -2,7 +2,6 @@ import type http from 'http';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService, type UserRole } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
@@ -15,7 +14,9 @@ import { handleLogsWs } from './logs';
|
||||
import { handleHostConsoleWs } from './hostConsole';
|
||||
import { handleGenericWs, attachGenericConnectionHandlers } from './generic';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat';
|
||||
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
||||
import { validateApiToken, touchApiTokenLastUsed } from '../utils/apiTokenAuth';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { isLicenseTier, normalizeTier, isLicenseVariant, normalizeVariant } from '../services/license-normalize';
|
||||
|
||||
@@ -84,13 +85,13 @@ export function attachUpgrade(
|
||||
let decoded: { username?: string; scope?: string; role?: string; tv?: number };
|
||||
let wsApiTokenScope: string | null = null;
|
||||
if (looksLikeApiToken(token)) {
|
||||
if (!verifyApiTokenChecksum(token)) return reject(socket, 401, 'Unauthorized');
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
||||
if (!apiToken || apiToken.revoked_at) return reject(socket, 401, 'Unauthorized');
|
||||
if (apiToken.expires_at && apiToken.expires_at < Date.now()) return reject(socket, 401, 'Unauthorized');
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
wsApiTokenScope = apiToken.scope;
|
||||
const validation = validateApiToken(token);
|
||||
if (!validation.ok) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] WS API token rejected:', validation.reason);
|
||||
return reject(socket, 401, 'Unauthorized');
|
||||
}
|
||||
touchApiTokenLastUsed(validation.token);
|
||||
wsApiTokenScope = validation.token.scope;
|
||||
decoded = { scope: 'api_token' };
|
||||
} else {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
|
||||
Reference in New Issue
Block a user