diff --git a/.changeset/world-local-path-traversal.md b/.changeset/world-local-path-traversal.md new file mode 100644 index 0000000000..6a802b14b3 --- /dev/null +++ b/.changeset/world-local-path-traversal.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-local": patch +--- + +Fix path traversal via request-supplied IDs in the `world-local` storage backend. diff --git a/packages/world-local/src/fs.test.ts b/packages/world-local/src/fs.test.ts index 84e69f5461..1acd79ecc0 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { WorkflowWorldError } from '@workflow/errors'; import type { PaginatedResponse } from '@workflow/world'; import ms from 'ms'; import { monotonicFactory } from 'ulid'; @@ -14,7 +15,16 @@ import { vi, } from 'vitest'; import { z } from 'zod'; -import { paginatedFileSystemQuery, ulidToDate, writeJSON } from './fs.js'; +import { + assertSafeEntityId, + paginatedFileSystemQuery, + readJSONWithFallback, + resolveWithinBase, + taggedPath, + UnsafeEntityIdError, + ulidToDate, + writeJSON, +} from './fs.js'; // Create a new monotonic ULID factory for each test to avoid state pollution let ulid = monotonicFactory(() => Math.random()); @@ -838,4 +848,129 @@ describe('fs utilities', () => { ]); }); }); + + describe('assertSafeEntityId (path traversal prevention)', () => { + // Values that should be accepted: actual entity IDs used by the system. + const safeIds = [ + 'wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'evnt_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'step_0', + 'step_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'hook_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'wrun_01ARZ3-step_01ARYY', // composite key with hyphen + 'vitest-0', // tag + 'strm_01ARZ3_user', // stream id with underscores + 'strm_01ARZ3_user_bmFtZXNwYWNl', // stream id with base64url namespace + 'wrun_ABC.vitest-0', // tagged file id + 'a', // minimal valid value + ]; + + // Values that should be rejected: real-world path traversal attempts. + const unsafeIds = [ + '', + '.', + '..', + '../foo', + '../../../package', + '../runs/wrun_01K8PSDCVBE9PBKXHR39AH15RE', + '..\\..\\windows', + 'foo/bar', + 'foo\\bar', + '/etc/passwd', + '.hidden', + '.locks', + '.tmp', + 'foo\0bar', // null byte + 'a/../b', + 'a\\..\\b', + ]; + + for (const id of safeIds) { + it(`accepts safe ID: ${JSON.stringify(id)}`, () => { + expect(() => assertSafeEntityId('test', id)).not.toThrow(); + }); + } + + for (const id of unsafeIds) { + it(`rejects unsafe ID: ${JSON.stringify(id)}`, () => { + expect(() => assertSafeEntityId('test', id)).toThrow( + UnsafeEntityIdError + ); + }); + } + + it('includes the kind label in the error message', () => { + expect(() => assertSafeEntityId('runId', '../escape')).toThrow( + /Unsafe runId/ + ); + }); + + it('taggedPath rejects path-traversal fileIds', () => { + expect(() => taggedPath(testDir, 'runs', '../escape')).toThrow( + UnsafeEntityIdError + ); + expect(() => taggedPath(testDir, 'runs', 'wrun_ABC', '../tag')).toThrow( + UnsafeEntityIdError + ); + }); + + it('taggedPath still produces correct paths for safe IDs', () => { + expect(taggedPath(testDir, 'runs', 'wrun_ABC')).toBe( + path.join(testDir, 'runs', 'wrun_ABC.json') + ); + expect(taggedPath(testDir, 'runs', 'wrun_ABC', 'vitest-0')).toBe( + path.join(testDir, 'runs', 'wrun_ABC.vitest-0.json') + ); + }); + + it('readJSONWithFallback rejects path-traversal fileIds', async () => { + const schema = z.object({ id: z.string() }); + await expect( + readJSONWithFallback(testDir, 'runs', '../package', schema) + ).rejects.toThrow(UnsafeEntityIdError); + }); + + it('UnsafeEntityIdError extends WorkflowWorldError', () => { + const err = new UnsafeEntityIdError('runId', '../escape'); + expect(err).toBeInstanceOf(WorkflowWorldError); + expect(err.name).toBe('UnsafeEntityIdError'); + expect(UnsafeEntityIdError.is(err)).toBe(true); + }); + + it('UnsafeEntityIdError truncates long values in the message', () => { + const longValue = 'a'.repeat(500); + const err = new UnsafeEntityIdError('runId', `${longValue}/escape`); + expect(err.message.length).toBeLessThan(200); + expect(err.message).toContain('…'); + }); + }); + + describe('resolveWithinBase (containment check)', () => { + it('resolves safe segments inside the base directory', () => { + const result = resolveWithinBase(testDir, 'runs', 'wrun_ABC.json'); + expect(result).toBe(path.join(testDir, 'runs', 'wrun_ABC.json')); + }); + + it('resolves to the base directory itself without error', () => { + expect(resolveWithinBase(testDir)).toBe(path.resolve(testDir)); + }); + + it('throws when a segment escapes the base via ..', () => { + expect(() => resolveWithinBase(testDir, '..', 'etc', 'passwd')).toThrow( + UnsafeEntityIdError + ); + }); + + it('throws when a segment is an absolute path', () => { + expect(() => resolveWithinBase(testDir, '/etc/passwd')).toThrow( + UnsafeEntityIdError + ); + }); + + it('throws when joined path escapes via chained ..', () => { + expect(() => + resolveWithinBase(testDir, 'runs', '..', '..', 'package.json') + ).toThrow(UnsafeEntityIdError); + }); + }); }); diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index c3aa711431..5cccf744a0 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -1,12 +1,93 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { EntityConflictError } from '@workflow/errors'; +import { EntityConflictError, WorkflowWorldError } from '@workflow/errors'; import type { PaginatedResponse } from '@workflow/world'; import { monotonicFactory } from 'ulid'; import { z } from 'zod'; const ulid = monotonicFactory(() => Math.random()); +/** + * Truncate a possibly-untrusted value for inclusion in an error message. + * Keeps error output actionable for developers in dev mode while limiting + * the amount of attacker-controlled data reflected back if a `world-local` + * backend is ever run in a context that surfaces these messages to clients. + */ +function truncateForError(value: unknown): string { + const s = typeof value === 'string' ? value : String(value); + const MAX = 48; + return s.length > MAX ? `${s.slice(0, MAX)}…` : s; +} + +/** + * Thrown when a caller-supplied entity ID contains characters that would + * escape the storage root (path traversal) or otherwise produce an unsafe + * filename. Extends {@link WorkflowWorldError} so the platform's standard + * error-to-HTTP mapping handles it alongside other world-layer errors. + */ +export class UnsafeEntityIdError extends WorkflowWorldError { + constructor(kind: string, value: string) { + super( + `Unsafe ${kind} "${truncateForError(value)}": must not be empty, start with ".", or contain path separators or null bytes` + ); + this.name = 'UnsafeEntityIdError'; + } + + static is(value: unknown): value is UnsafeEntityIdError { + return value instanceof Error && value.name === 'UnsafeEntityIdError'; + } +} + +/** + * Validate that a string is safe to embed in a filesystem path as a single + * path component. Rejects values that are: + * - empty + * - starting with `.` (blocks `.`, `..`, `.locks`, `.tmp`, and other + * hidden or reserved filenames) + * - containing `/`, `\`, or a NUL byte + * + * This is the primary defense against path-traversal attacks where a + * request body supplies a `runId` / `stepId` / `correlationId` containing + * `../` sequences to read or write files outside the storage root. + * {@link resolveWithinBase} provides a belt-and-suspenders containment + * check at the point of `path.join` for defense in depth. + * + * @param kind - Human-readable label used in the error message (e.g. "runId") + * @param value - The value to validate; throws {@link UnsafeEntityIdError} + * if the value is not safe. + */ +export function assertSafeEntityId(kind: string, value: string): void { + if ( + value.length === 0 || + value.startsWith('.') || + value.includes('/') || + value.includes('\\') || + value.includes('\0') + ) { + throw new UnsafeEntityIdError(kind, value); + } +} + +/** + * Join `basedir` with an entity-relative path and assert the result stays + * inside `basedir`. Complements {@link assertSafeEntityId}: per-entry-point + * validation is the primary defense, and this final check catches any path + * escape that slipped past (e.g. a new call site that forgot to validate, + * or an unusual character combination on a future filesystem). Throws + * {@link UnsafeEntityIdError} if the joined path escapes `basedir`. + */ +export function resolveWithinBase( + basedir: string, + ...segments: string[] +): string { + const resolvedBase = path.resolve(basedir); + const joined = path.resolve(basedir, ...segments); + if (joined !== resolvedBase && !joined.startsWith(resolvedBase + path.sep)) { + throw new UnsafeEntityIdError('path', segments.join('/')); + } + return joined; +} + const isWindows = process.platform === 'win32'; /** @@ -82,8 +163,13 @@ export function hasTag(fileId: string, tag: string): boolean { /** * Build the file path for an entity, with optional tag embedded in the filename. - * `taggedPath('runs', 'wrun_ABC', 'vitest-0')` → `runs/wrun_ABC.vitest-0.json` - * `taggedPath('runs', 'wrun_ABC')` → `runs/wrun_ABC.json` + * `taggedPath('/data', 'runs', 'wrun_ABC', 'vitest-0')` → `/data/runs/wrun_ABC.vitest-0.json` + * `taggedPath('/data', 'runs', 'wrun_ABC')` → `/data/runs/wrun_ABC.json` + * + * The `fileId` and `tag` are validated with {@link assertSafeEntityId} and + * the result is containment-checked with {@link resolveWithinBase} to + * prevent path-traversal attacks when values are derived from untrusted + * request input. */ export function taggedPath( basedir: string, @@ -91,8 +177,10 @@ export function taggedPath( fileId: string, tag?: string ): string { + assertSafeEntityId('fileId', fileId); + if (tag !== undefined) assertSafeEntityId('tag', tag); const filename = tag ? `${fileId}.${tag}.json` : `${fileId}.json`; - return path.join(basedir, entityDir, filename); + return resolveWithinBase(basedir, entityDir, filename); } /** @@ -107,14 +195,19 @@ export async function readJSONWithFallback( schema: z.ZodType, tag?: string ): Promise { + assertSafeEntityId('fileId', fileId); + if (tag !== undefined) assertSafeEntityId('tag', tag); if (tag) { const result = await readJSON( - path.join(basedir, entityDir, `${fileId}.${tag}.json`), + resolveWithinBase(basedir, entityDir, `${fileId}.${tag}.json`), schema ); if (result !== null) return result; } - return readJSON(path.join(basedir, entityDir, `${fileId}.json`), schema); + return readJSON( + resolveWithinBase(basedir, entityDir, `${fileId}.json`), + schema + ); } /** @@ -374,6 +467,15 @@ export async function paginatedFileSystemQuery( getId, } = config; + // Validate filePrefix (typically `${runId}-`) so request-derived prefixes + // consistently reject unsafe characters. filePrefix is only used below to + // filter readdir() results by prefix — it doesn't participate in path + // construction — but keeping the validation rule uniform across the + // storage layer avoids special cases and catches bad values earlier. + if (filePrefix !== undefined) { + assertSafeEntityId('filePrefix', filePrefix); + } + // 1. Get all JSON files in directory const fileIds = await listJSONFiles(directory); diff --git a/packages/world-local/src/storage.test.ts b/packages/world-local/src/storage.test.ts index 71632f98f6..d94e17ccbe 100644 --- a/packages/world-local/src/storage.test.ts +++ b/packages/world-local/src/storage.test.ts @@ -2928,4 +2928,78 @@ describe('Storage', () => { expect(result.run!.runId).toMatch(/^wrun_/); }); }); + + // Regression tests for VULN-916: path-traversal via request-controlled IDs. + // + // Prior to the fix, a client could supply a `runId` like `../../../package` + // and cause the backend to read/write files outside the storage root, since + // the IDs flowed straight into `path.join(basedir, 'runs', ...)`. The + // sanitization in `assertSafeEntityId` rejects any separator/`..`/leading + // dot before the value is used in a filesystem path. + describe('path traversal prevention (VULN-916)', () => { + const traversalIds = [ + '../../../package', + '../runs/wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV', + '../nonexistent/wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'a/b', + 'a\\b', + '.hidden', + '.locks', + ]; + + for (const runId of traversalIds) { + it(`rejects traversal runId on events.create: ${JSON.stringify(runId)}`, async () => { + await expect( + storage.events.create(runId, { eventType: 'run_started' }) + ).rejects.toThrow(/Unsafe runId/); + }); + + it(`rejects traversal runId on runs.get: ${JSON.stringify(runId)}`, async () => { + await expect(storage.runs.get(runId)).rejects.toThrow(/Unsafe runId/); + }); + + it(`rejects traversal runId on steps.list: ${JSON.stringify(runId)}`, async () => { + await expect(storage.steps.list({ runId } as any)).rejects.toThrow( + /Unsafe runId/ + ); + }); + + it(`rejects traversal runId on events.list: ${JSON.stringify(runId)}`, async () => { + await expect(storage.events.list({ runId } as any)).rejects.toThrow( + /Unsafe runId/ + ); + }); + } + + it('rejects traversal stepId on steps.get', async () => { + const run = await createRun(storage, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + await expect(storage.steps.get(run.runId, '../escape')).rejects.toThrow( + /Unsafe stepId/ + ); + }); + + it('rejects traversal correlationId on events.create', async () => { + const run = await createRun(storage, { + deploymentId: 'deployment-123', + workflowName: 'test-workflow', + input: new Uint8Array(), + }); + await expect( + storage.events.create(run.runId, { + eventType: 'step_started', + correlationId: '../escape', + }) + ).rejects.toThrow(/Unsafe correlationId/); + }); + + it('rejects traversal hookId on hooks.get', async () => { + await expect(storage.hooks.get('../escape')).rejects.toThrow( + /Unsafe hookId/ + ); + }); + }); }); diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 63df4a81c0..fd6060f451 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -31,11 +31,13 @@ import { } from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { + assertSafeEntityId, deleteJSON, jsonReplacer, listJSONFiles, paginatedFileSystemQuery, readJSONWithFallback, + resolveWithinBase, taggedPath, writeExclusive, writeJSON, @@ -79,6 +81,21 @@ export function createEventsStorage( const eventId = `evnt_${monotonicUlid()}`; const now = new Date(); + // Validate request-supplied IDs before they're concatenated into + // filesystem paths. This is the primary defense against path traversal + // attacks where a client supplies runId / correlationId values like + // "../../../package" to read or write files outside the storage root. + if (runId != null && runId !== '') { + assertSafeEntityId('runId', runId); + } + if ( + 'correlationId' in data && + typeof data.correlationId === 'string' && + data.correlationId.length > 0 + ) { + assertSafeEntityId('correlationId', data.correlationId); + } + // For run_created events, use client-provided runId or generate one server-side let effectiveRunId: string; if (data.eventType === 'run_created' && (!runId || runId === '')) { @@ -651,7 +668,7 @@ export function createEventsStorage( const lockName = tag ? `${stepCompositeKey}.terminal.${tag}` : `${stepCompositeKey}.terminal`; - const terminalLockPath = path.join( + const terminalLockPath = resolveWithinBase( basedir, '.locks', 'steps', @@ -689,7 +706,7 @@ export function createEventsStorage( const lockName = tag ? `${stepCompositeKey}.terminal.${tag}` : `${stepCompositeKey}.terminal`; - const terminalLockPath = path.join( + const terminalLockPath = resolveWithinBase( basedir, '.locks', 'steps', @@ -837,7 +854,12 @@ export function createEventsStorage( const hookLockName = tag ? `${data.correlationId}.disposed.${tag}` : `${data.correlationId}.disposed`; - const lockPath = path.join(basedir, '.locks', 'hooks', hookLockName); + const lockPath = resolveWithinBase( + basedir, + '.locks', + 'hooks', + hookLockName + ); const claimed = await writeExclusive(lockPath, ''); if (!claimed) { throw new EntityConflictError( @@ -904,7 +926,12 @@ export function createEventsStorage( const waitLockName = tag ? `${waitCompositeKey}.completed.${tag}` : `${waitCompositeKey}.completed`; - const lockPath = path.join(basedir, '.locks', 'waits', waitLockName); + const lockPath = resolveWithinBase( + basedir, + '.locks', + 'waits', + waitLockName + ); const claimed = await writeExclusive(lockPath, ''); if (!claimed) { throw new EntityConflictError( @@ -976,6 +1003,8 @@ export function createEventsStorage( }, async get(runId, eventId, params) { + assertSafeEntityId('runId', runId); + assertSafeEntityId('eventId', eventId); const compositeKey = `${runId}-${eventId}`; const event = await readJSONWithFallback( basedir, @@ -993,6 +1022,7 @@ export function createEventsStorage( async list(params) { const { runId } = params; + assertSafeEntityId('runId', runId); const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), @@ -1022,6 +1052,7 @@ export function createEventsStorage( async listByCorrelationId(params) { const correlationId = params.correlationId; + assertSafeEntityId('correlationId', correlationId); const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'events'), diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index 8992b19db0..93fe7d8236 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -10,6 +10,7 @@ import type { import { HookSchema } from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { + assertSafeEntityId, deleteJSON, listJSONFiles, paginatedFileSystemQuery, @@ -44,6 +45,7 @@ export function createHooksStorage( } async function get(hookId: string, params?: GetHookParams): Promise { + assertSafeEntityId('hookId', hookId); const hook = await readJSONWithFallback( basedir, 'hooks', diff --git a/packages/world-local/src/storage/legacy.ts b/packages/world-local/src/storage/legacy.ts index 0ca6004040..bd1915c066 100644 --- a/packages/world-local/src/storage/legacy.ts +++ b/packages/world-local/src/storage/legacy.ts @@ -1,8 +1,7 @@ -import path from 'node:path'; import type { Event, EventResult, WorkflowRun } from '@workflow/world'; import { SPEC_VERSION_CURRENT } from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; -import { writeJSON } from '../fs.js'; +import { assertSafeEntityId, resolveWithinBase, writeJSON } from '../fs.js'; import { filterRunData, stripEventDataRefs } from './filters.js'; import { monotonicUlid } from './helpers.js'; import { deleteAllHooksForRun } from './hooks-storage.js'; @@ -22,6 +21,12 @@ export async function handleLegacyEvent( currentRun: WorkflowRun, params?: { resolveData?: 'none' | 'all' } ): Promise { + // Defense in depth: events.create already validates runId before routing + // here, but handleLegacyEvent is exported and its signature doesn't + // document that invariant. Validating locally keeps the guarantee in this + // file. + assertSafeEntityId('runId', runId); + const resolveData = params?.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; switch (data.eventType) { @@ -44,7 +49,7 @@ export async function handleLegacyEvent( completedAt: now, updatedAt: now, }; - const runPath = path.join(basedir, 'runs', `${runId}.json`); + const runPath = resolveWithinBase(basedir, 'runs', `${runId}.json`); await writeJSON(runPath, run, { overwrite: true }); await deleteAllHooksForRun(basedir, runId); // Return without event (legacy behavior skips event storage) @@ -70,7 +75,11 @@ export async function handleLegacyEvent( specVersion: SPEC_VERSION_CURRENT, }; const compositeKey = `${runId}-${eventId}`; - const eventPath = path.join(basedir, 'events', `${compositeKey}.json`); + const eventPath = resolveWithinBase( + basedir, + 'events', + `${compositeKey}.json` + ); await writeJSON(eventPath, event); return { event: stripEventDataRefs(event, resolveData) }; } diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index 1080fbde4f..76e7891404 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -9,7 +9,11 @@ import type { } from '@workflow/world'; import { WorkflowRunSchema } from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; -import { paginatedFileSystemQuery, readJSONWithFallback } from '../fs.js'; +import { + assertSafeEntityId, + paginatedFileSystemQuery, + readJSONWithFallback, +} from '../fs.js'; import { filterRunData } from './filters.js'; import { getObjectCreatedAt } from './helpers.js'; @@ -49,6 +53,7 @@ export function createRunsStorage( ): LocalRunsStorage { return { get: (async (id: string, params?: any) => { + assertSafeEntityId('runId', id); const run = await readJSONWithFallback( basedir, 'runs', diff --git a/packages/world-local/src/storage/steps-storage.ts b/packages/world-local/src/storage/steps-storage.ts index 50dfb41c81..163ea346ee 100644 --- a/packages/world-local/src/storage/steps-storage.ts +++ b/packages/world-local/src/storage/steps-storage.ts @@ -3,6 +3,7 @@ import type { StepWithoutData, Storage } from '@workflow/world'; import { StepSchema } from '@workflow/world'; import { DEFAULT_RESOLVE_DATA_OPTION } from '../config.js'; import { + assertSafeEntityId, listJSONFiles, paginatedFileSystemQuery, readJSONWithFallback, @@ -21,6 +22,7 @@ export function createStepsStorage( ): Storage['steps'] { return { get: (async (runId: string | undefined, stepId: string, params?: any) => { + assertSafeEntityId('stepId', stepId); if (!runId) { const fileIds = await listJSONFiles(path.join(basedir, 'steps')); const fileId = fileIds.find((fid) => @@ -30,6 +32,8 @@ export function createStepsStorage( throw new Error(`Step ${stepId} not found`); } runId = stripTag(fileId).split('-')[0]; + } else { + assertSafeEntityId('runId', runId); } const compositeKey = `${runId}-${stepId}`; const step = await readJSONWithFallback( @@ -47,6 +51,7 @@ export function createStepsStorage( }) as Storage['steps']['get'], list: (async (params: any) => { + assertSafeEntityId('runId', params.runId); const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION; const result = await paginatedFileSystemQuery({ directory: path.join(basedir, 'steps'), diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index bdc3d7ad38..60436f8866 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -9,6 +9,7 @@ import type { import { monotonicFactory } from 'ulid'; import { z } from 'zod'; import { + assertSafeEntityId, listFilesByExtension, readBuffer, readJSONWithFallback, @@ -65,6 +66,8 @@ async function listChunkFilesForStream( name: string, tag?: string ): Promise<{ files: string[]; extMap: Map }> { + // Name is used as a filename prefix below; validate it can't escape chunksDir. + assertSafeEntityId('streamName', name); const listPromises: Promise[] = [ listFilesByExtension(chunksDir, '.bin'), listFilesByExtension(chunksDir, '.json'), @@ -117,6 +120,8 @@ export function createStreamer(basedir: string, tag?: string): Streamer { runId: string, streamName: string ): Promise { + assertSafeEntityId('runId', runId); + assertSafeEntityId('streamName', streamName); const cacheKey = `${runId}:${streamName}`; if (registeredStreams.has(cacheKey)) { return; // Already registered in this session @@ -281,6 +286,7 @@ export function createStreamer(basedir: string, tag?: string): Streamer { }, async listStreamsByRunId(runId: string) { + assertSafeEntityId('runId', runId); const data = await readJSONWithFallback( basedir, 'streams/runs',