mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
2435da232b
* 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.
80 lines
3.3 KiB
TypeScript
80 lines
3.3 KiB
TypeScript
/**
|
|
* Integration tests for API-token scope enforcement on the WebSocket upgrade.
|
|
* Restricted scopes (read-only, deploy-only) may reach only stack logs and
|
|
* notifications; every other WS path (host console, generic) is 403'd by the
|
|
* scope gate before dispatch. Driven through a real listening server, no mocks.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import WebSocket from 'ws';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { createTestApiToken } from './helpers/apiTokenTestHelper';
|
|
|
|
let tmpDir: string;
|
|
let server: import('http').Server;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
function createToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string {
|
|
const db = DatabaseService.getInstance();
|
|
return createTestApiToken({ db: DatabaseService, scope, userId: db.getUserByUsername('testadmin')!.id });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
vi.restoreAllMocks();
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
const mod = await import('../index');
|
|
server = mod.server;
|
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
function wsUrl(path: string): string {
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('Server not listening');
|
|
return `ws://127.0.0.1:${addr.port}${path}`;
|
|
}
|
|
|
|
/** Resolve to the rejected-upgrade HTTP status, or 200 if the socket opens. */
|
|
function upgradeStatus(token: string, path: string): Promise<number> {
|
|
return new Promise<number>((resolve) => {
|
|
const ws = new WebSocket(wsUrl(path), { headers: { Authorization: `Bearer ${token}` } });
|
|
ws.on('unexpected-response', (_req, res) => { ws.close(); resolve(res.statusCode ?? 0); });
|
|
ws.on('open', () => { ws.close(); resolve(200); });
|
|
ws.on('error', () => resolve(0));
|
|
});
|
|
}
|
|
|
|
describe('WebSocket API-token scope enforcement', () => {
|
|
it('blocks a read-only token from the host console (403)', async () => {
|
|
expect(await upgradeStatus(createToken('read-only'), '/api/system/host-console')).toBe(403);
|
|
});
|
|
|
|
it('blocks a deploy-only token from the host console (403)', async () => {
|
|
expect(await upgradeStatus(createToken('deploy-only'), '/api/system/host-console')).toBe(403);
|
|
});
|
|
|
|
it('blocks a read-only token from a generic socket (403)', async () => {
|
|
expect(await upgradeStatus(createToken('read-only'), '/ws')).toBe(403);
|
|
});
|
|
|
|
it('does not scope-block a read-only token from notifications', async () => {
|
|
expect(await upgradeStatus(createToken('read-only'), '/ws/notifications')).not.toBe(403);
|
|
});
|
|
|
|
it('does not scope-block a deploy-only token from notifications', async () => {
|
|
expect(await upgradeStatus(createToken('deploy-only'), '/ws/notifications')).not.toBe(403);
|
|
});
|
|
|
|
it('does not scope-block a read-only token from stack logs', async () => {
|
|
expect(await upgradeStatus(createToken('read-only'), '/api/stacks/test-stack/logs')).not.toBe(403);
|
|
});
|
|
|
|
it('does not scope-block a full-admin token from a generic socket', async () => {
|
|
expect(await upgradeStatus(createToken('full-admin'), '/ws')).not.toBe(403);
|
|
});
|
|
});
|