Spaces:
Runtime error
Runtime error
File size: 3,476 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | // Structural guard for the cross-tenant scoping class of bug (audit + webhook delivery-failures).
//
// The ApiKeyGuard session fence resolves the scoped sessionId from ROUTE PARAMS only
// (api-key.guard.ts). So any handler that instead accepts `sessionId` as a QUERY param is NOT
// scoped by the guard, and must derive scope from the calling key itself — the established pattern is
// to inject `@CurrentApiKey()` and pass `apiKey.allowedSessions` to the service (see
// search.controller / webhooks-list findAll / audit). This test fails if a controller handler takes
// `@Query('sessionId')` without also injecting `@CurrentApiKey`, so a future endpoint cannot silently
// re-introduce the leak.
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
/**
* Return the names of handlers in `source` that take a `sessionId` query param but do NOT inject
* `@CurrentApiKey` in the same parameter list — i.e. that bypass the guard fence without re-scoping.
*/
export function handlersMissingSessionScope(source: string): string[] {
const offenders: string[] = [];
// Match a 2-space-indented method declaration and capture its parameter list: `name( <params> ):`.
// Decorators (`@Get(...)`) start with `@`, so they are not matched as method names.
const methodRe = /^ {2}(?:async\s+)?([a-zA-Z0-9_]+)\s*\(([\s\S]*?)\)\s*:/gm;
for (let m = methodRe.exec(source); m !== null; m = methodRe.exec(source)) {
const [, name, params] = m;
const takesSessionIdQuery = /@Query\(\s*['"]sessionId['"]\s*\)/.test(params);
const injectsCurrentApiKey = /@CurrentApiKey\(/.test(params);
if (takesSessionIdQuery && !injectsCurrentApiKey) offenders.push(name);
}
return offenders;
}
function listControllerFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...listControllerFiles(full));
else if (entry.name.endsWith('.controller.ts') && !entry.name.endsWith('.spec.ts')) out.push(full);
}
return out;
}
describe('query-param sessionId endpoints are session-scoped', () => {
// The checker itself must actually detect the leak — a structural guard that can't fail proves nothing.
it('flags a handler that takes @Query(sessionId) without @CurrentApiKey', () => {
const vulnerable = `
async findAll(
@Query('sessionId') sessionId?: string,
@Query('limit') limit?: string,
): Promise<unknown> {
return this.svc.findAll(sessionId);
}
`;
expect(handlersMissingSessionScope(vulnerable)).toEqual(['findAll']);
});
it('clears a handler that injects @CurrentApiKey alongside the query param', () => {
const fixed = `
async findAll(
@CurrentApiKey() apiKey?: ApiKey,
@Query('sessionId') sessionId?: string,
): Promise<unknown> {
return this.svc.findAll(sessionId, apiKey?.allowedSessions);
}
`;
expect(handlersMissingSessionScope(fixed)).toEqual([]);
});
it('no real controller takes @Query(sessionId) without scoping to the calling key', () => {
const modulesDir = join(__dirname, '..');
const offenders: string[] = [];
for (const file of listControllerFiles(modulesDir)) {
for (const handler of handlersMissingSessionScope(readFileSync(file, 'utf8'))) {
offenders.push(`${file.replace(/.*\/src\//, 'src/')} :: ${handler}`);
}
}
expect(offenders).toEqual([]);
});
});
|