fix(security): M2-008 Valkey key audit — SCAN over KEYS, restrict /gc to admin (#298)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #298.
This commit is contained in:
2026-03-21 20:45:43 +00:00
committed by jason.woltje
parent 02ff3b3256
commit 5b089392fd
5 changed files with 58 additions and 26 deletions

View File

@@ -56,6 +56,22 @@ export class SessionGCService implements OnModuleInit {
});
}
/**
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
*/
private async scanKeys(pattern: string): Promise<string[]> {
const collected: string[] = [];
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
collected.push(...keys);
} while (cursor !== '0');
return collected;
}
/**
* Immediate cleanup for a single session (call from destroySession).
*/
@@ -64,7 +80,7 @@ export class SessionGCService implements OnModuleInit {
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.redis.keys(pattern);
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
@@ -82,14 +98,15 @@ export class SessionGCService implements OnModuleInit {
/**
* Sweep GC — find orphaned artifacts from dead sessions.
* User-scoped when userId provided; system-wide when null (admin).
* System-wide operation: only call from admin-authorized paths or internal
* scheduled jobs. Individual session cleanup is handled by collect().
*/
async sweepOrphans(_userId?: string): Promise<GCSweepResult> {
async sweepOrphans(): Promise<GCSweepResult> {
const start = Date.now();
const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys
const allSessionKeys = await this.redis.keys('mosaic:session:*');
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys
const sessionIds = new Set<string>();
@@ -120,8 +137,8 @@ export class SessionGCService implements OnModuleInit {
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
// 1. Valkey: delete ALL session-scoped keys
const sessionKeys = await this.redis.keys('mosaic:session:*');
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}