From 375169a69b76f79f433484bc9f2761d2be4fd547 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 09:54:30 +0200 Subject: [PATCH] fix(stalker): harden replay network boundaries --- .../app/replay/replay-control-plane.spec.ts | 46 ++ .../src/app/replay/replay-control-plane.ts | 302 ++++++---- .../src/app/replay/replay-response.ts | 59 +- .../src/app/replay/replay-run.spec.ts | 173 ++++++ .../src/app/replay/replay-run.ts | 121 +++- .../src/app/replay/replay-server.spec.ts | 379 ++++++++++++- .../src/app/replay/replay-server.ts | 210 ++++++- .../src/lib/replay-schema.spec.ts | 176 ++++++ .../replay-fixtures/src/lib/replay-schema.ts | 533 +++++++++++++++++- .../src/lib/replay.constants.ts | 2 + .../replay-fixtures/src/lib/replay.types.ts | 7 + 11 files changed, 1819 insertions(+), 189 deletions(-) diff --git a/apps/stalker-mock-server/src/app/replay/replay-control-plane.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-control-plane.spec.ts index cb78e8208..3056c7721 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-control-plane.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-control-plane.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- The control-plane security boundary is clearer as explicit wire-level cases. */ import { + link, mkdtemp, mkdir, rm, @@ -428,6 +429,10 @@ describe('replay fixture repository allowlist', () => { outsidePath, path.join(repositoryRoot, 'authentication', 'escape.json') ); + await link( + outsidePath, + path.join(repositoryRoot, 'authentication', 'hardlink.json') + ); const repository = new RepositoryReplayFixtureRepository( repositoryRoot ); @@ -448,6 +453,11 @@ describe('replay fixture repository allowlist', () => { ).rejects.toMatchObject({ code: 'fixture-not-allowed', }); + await expect( + repository.loadFixture('authentication/hardlink') + ).rejects.toMatchObject({ + code: 'fixture-not-allowed', + }); await expect( repository.loadFixture('authentication/missing') ).rejects.toMatchObject({ @@ -457,6 +467,42 @@ describe('replay fixture repository allowlist', () => { }); describe('replay control-plane lifecycle and public response', () => { + it('waits for an in-flight create and disposes the run before close resolves', async () => { + let releaseFixture!: () => void; + let markLoadStarted!: () => void; + const fixtureReleased = new Promise((resolve) => { + releaseFixture = resolve; + }); + const loadStarted = new Promise((resolve) => { + markLoadStarted = resolve; + }); + const control = await startReplayControlPlane({ + capability: TEST_CAPABILITY, + repository: { + async loadFixture(): Promise { + markLoadStarted(); + await fixtureReleased; + return replayFixture(); + }, + }, + }); + + const creating = authorizedRequest(control.url, 'create', { + fixtureId: 'authentication/deferred', + }); + await loadStarted; + const closing = control.close(); + releaseFixture(); + + const created = await creating; + expect(created.status).toBe(201); + const entryUrl = (created.body as { entryUrl: string }).entryUrl; + await closing; + + await expect(fetch(entryUrl)).rejects.toThrow(); + await expect(control.close()).resolves.toBeUndefined(); + }); + it('returns only opaque routing and filtered public inputs, then finalizes and disposes the run', async () => { const control = await startReplayControlPlane({ capability: TEST_CAPABILITY, diff --git a/apps/stalker-mock-server/src/app/replay/replay-control-plane.ts b/apps/stalker-mock-server/src/app/replay/replay-control-plane.ts index 98ad3e8aa..c0fc9fda3 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-control-plane.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-control-plane.ts @@ -38,6 +38,7 @@ export const REPLAY_CONTROL_MAX_BODY_BYTES = 64 * 1024; const CONTROL_ERROR_CODE = { BODY_TOO_LARGE: 'control-body-too-large', BODY_TIMEOUT: 'control-body-timeout', + CLOSING: 'control-closing', ENDPOINT_NOT_FOUND: 'endpoint-not-found', FIXTURE_INVALID: 'fixture-invalid', FIXTURE_NOT_ALLOWED: 'fixture-not-allowed', @@ -120,6 +121,40 @@ function isWithinRoot(root: string, candidate: string): boolean { ); } +interface StableFixtureMetadata { + dev: number; + ino: number; + mode: number; + nlink: number; + size: number; + mtimeMs: number; + ctimeMs: number; + isFile(): boolean; +} + +function isAllowedFixtureMetadata(stats: StableFixtureMetadata): boolean { + return ( + stats.isFile() && + stats.nlink === 1 && + stats.size <= REPLAY_MAX_FIXTURE_BYTES + ); +} + +function hasSameFixtureMetadata( + left: StableFixtureMetadata, + right: StableFixtureMetadata +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + async function readBoundedFixture(handle: FileHandle): Promise { const chunks: Buffer[] = []; const readBuffer = Buffer.alloc(64 * 1024); @@ -161,7 +196,7 @@ export class RepositoryReplayFixtureRepository const requestedStats = await lstat(requestedPath); if ( requestedStats.isSymbolicLink() || - !requestedStats.isFile() + !isAllowedFixtureMetadata(requestedStats) ) { throw new ReplayFixtureRepositoryError( 'fixture-not-allowed' @@ -176,21 +211,36 @@ export class RepositoryReplayFixtureRepository const handle = await open( requestedPath, - fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW + fsConstants.O_RDONLY | + fsConstants.O_NOFOLLOW | + fsConstants.O_NONBLOCK ); try { - const stats = await handle.stat(); + const statsBeforeRead = await handle.stat(); if ( - !stats.isFile() || - stats.size > REPLAY_MAX_FIXTURE_BYTES || - stats.dev !== requestedStats.dev || - stats.ino !== requestedStats.ino + !isAllowedFixtureMetadata(statsBeforeRead) || + !hasSameFixtureMetadata( + requestedStats, + statsBeforeRead + ) ) { throw new ReplayFixtureRepositoryError( 'fixture-not-allowed' ); } const text = await readBoundedFixture(handle); + const statsAfterRead = await handle.stat(); + if ( + !isAllowedFixtureMetadata(statsAfterRead) || + !hasSameFixtureMetadata( + statsBeforeRead, + statsAfterRead + ) + ) { + throw new ReplayFixtureRepositoryError( + 'fixture-not-allowed' + ); + } return parseReplayFixtureText(text); } finally { await handle.close(); @@ -503,121 +553,139 @@ export async function startReplayControlPlane( } const runs = new Map(); let expectedHost = ''; + let closing = false; + const inFlightHandlers = new Set>(); - const server = http.createServer(async (request, response) => { - try { - if (request.headers.host !== expectedHost) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.INVALID_HOST, - 400 - ); - } - const capabilityHeader = - request.headers[REPLAY_CONTROL_CAPABILITY_HEADER]; - if ( - Array.isArray(capabilityHeader) || - !capabilityMatches(capabilityHeader, capability) - ) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.INVALID_CAPABILITY, - 403 - ); - } - const actionPath = requestPath(request); - if ( - actionPath !== CREATE_PATH && - actionPath !== FINALIZE_PATH && - actionPath !== DISPOSE_PATH - ) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.ENDPOINT_NOT_FOUND, - 404 - ); - } - if (request.method !== 'POST') { - throw new ReplayControlError( - CONTROL_ERROR_CODE.METHOD_NOT_ALLOWED, - 405 - ); - } - const body = await parseJsonBody( - request, - requestBodyTimeoutMs - ); - - if (actionPath === CREATE_PATH) { - const fixtureId = readFixtureId(body); - let fixture: ReplayFixtureV1; - try { - fixture = await repository.loadFixture(fixtureId); - } catch (error) { - if ( - error instanceof ReplayFixtureRepositoryError && - error.code === 'fixture-not-allowed' - ) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.FIXTURE_NOT_ALLOWED, - 404 - ); - } - if ( - error instanceof ReplayFixtureRepositoryError && - error.code === 'fixture-invalid' - ) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.FIXTURE_INVALID, - 422 - ); - } - throw error; - } - const run = await createReplayServerRun(fixture); - if (runs.has(run.runId)) { - await run.dispose(); + const server = http.createServer((request, response) => { + const handler = (async (): Promise => { + try { + if (closing) { throw new ReplayControlError( - CONTROL_ERROR_CODE.INTERNAL, - 500 + CONTROL_ERROR_CODE.CLOSING, + 503 ); } - runs.set(run.runId, run); - sendJson(response, 201, { - runId: run.runId, - entryUrl: run.entryUrl, - origins: run.originUrls, - inputs: run.generatedInputs, - }); - return; - } - - const runId = readRunId(body); - const run = runs.get(runId); - if (run === undefined) { - throw new ReplayControlError( - CONTROL_ERROR_CODE.RUN_NOT_FOUND, - 404 + if (request.headers.host !== expectedHost) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.INVALID_HOST, + 400 + ); + } + const capabilityHeader = + request.headers[REPLAY_CONTROL_CAPABILITY_HEADER]; + if ( + Array.isArray(capabilityHeader) || + !capabilityMatches(capabilityHeader, capability) + ) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.INVALID_CAPABILITY, + 403 + ); + } + const actionPath = requestPath(request); + if ( + actionPath !== CREATE_PATH && + actionPath !== FINALIZE_PATH && + actionPath !== DISPOSE_PATH + ) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.ENDPOINT_NOT_FOUND, + 404 + ); + } + if (request.method !== 'POST') { + throw new ReplayControlError( + CONTROL_ERROR_CODE.METHOD_NOT_ALLOWED, + 405 + ); + } + const body = await parseJsonBody( + request, + requestBodyTimeoutMs ); - } - if (actionPath === FINALIZE_PATH) { - sendJson(response, 200, run.finalize()); - return; - } - await run.dispose(); - runs.delete(runId); - sendJson(response, 200, { disposed: true }); - } catch (error) { - if (!request.complete) { - request.resume(); + if (actionPath === CREATE_PATH) { + const fixtureId = readFixtureId(body); + let fixture: ReplayFixtureV1; + try { + fixture = await repository.loadFixture(fixtureId); + } catch (error) { + if ( + error instanceof ReplayFixtureRepositoryError && + error.code === 'fixture-not-allowed' + ) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.FIXTURE_NOT_ALLOWED, + 404 + ); + } + if ( + error instanceof ReplayFixtureRepositoryError && + error.code === 'fixture-invalid' + ) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.FIXTURE_INVALID, + 422 + ); + } + throw error; + } + const run = await createReplayServerRun(fixture); + if (runs.has(run.runId)) { + await run.dispose(); + throw new ReplayControlError( + CONTROL_ERROR_CODE.INTERNAL, + 500 + ); + } + runs.set(run.runId, run); + sendJson(response, 201, { + runId: run.runId, + entryUrl: run.entryUrl, + origins: run.originUrls, + inputs: run.generatedInputs, + }); + return; + } + + const runId = readRunId(body); + const run = runs.get(runId); + if (run === undefined) { + throw new ReplayControlError( + CONTROL_ERROR_CODE.RUN_NOT_FOUND, + 404 + ); + } + if (actionPath === FINALIZE_PATH) { + sendJson(response, 200, run.finalize()); + return; + } + + await run.dispose(); + runs.delete(runId); + sendJson(response, 200, { disposed: true }); + } catch (error) { + if (!request.complete) { + request.resume(); + } + if (error instanceof ReplayControlError) { + sendControlError(response, error); + } else { + sendControlError( + response, + new ReplayControlError( + CONTROL_ERROR_CODE.INTERNAL, + 500 + ) + ); + } } - if (error instanceof ReplayControlError) { - sendControlError(response, error); - } else { - sendControlError( - response, - new ReplayControlError(CONTROL_ERROR_CODE.INTERNAL, 500) - ); - } - } + })(); + inFlightHandlers.add(handler); + void handler.then( + () => inFlightHandlers.delete(handler), + () => inFlightHandlers.delete(handler) + ); }); const port = await listen(server); @@ -628,11 +696,13 @@ export async function startReplayControlPlane( url, close: () => { closePromise ??= (async () => { + closing = true; + await closeServer(server); + await Promise.all([...inFlightHandlers]); await Promise.all( [...runs.values()].map((run) => run.dispose()) ); runs.clear(); - await closeServer(server); })(); return closePromise; }, diff --git a/apps/stalker-mock-server/src/app/replay/replay-response.ts b/apps/stalker-mock-server/src/app/replay/replay-response.ts index 48437f7c5..d9b626b85 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-response.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-response.ts @@ -9,7 +9,9 @@ import { export type ReplayOriginUrlMap = Readonly>; export class ReplayResponseError extends Error { - constructor(readonly code: 'unknown-origin-url') { + constructor( + readonly code: 'unknown-origin-url' | 'invalid-origin-url' + ) { super(`Replay response rejected: ${code}.`); this.name = 'ReplayResponseError'; } @@ -33,7 +35,60 @@ function renderHeaderValue( if (originUrl === undefined) { throw new ReplayResponseError('unknown-origin-url'); } - return `${originUrl}${value.path}`; + try { + const base = new URL(originUrl); + const target = new URL(base.href); + target.pathname = `${base.pathname}${value.path}`; + target.search = ''; + target.hash = ''; + if ( + target.origin !== base.origin || + !target.pathname.startsWith(`${base.pathname}/`) + ) { + throw new Error('named-origin path escaped its run prefix'); + } + if (value.userInfo !== undefined) { + target.username = symbols.resolveString( + value.userInfo.username + ); + target.password = + value.userInfo.password === undefined + ? '' + : symbols.resolveString(value.userInfo.password); + } + for (const [name, queryValue] of Object.entries( + value.query ?? {} + )) { + const values = Array.isArray(queryValue) + ? queryValue + : [queryValue]; + for (const item of values) { + target.searchParams.append( + name, + symbols.resolveString(item) + ); + } + } + return target.href; + } catch (error) { + if (error instanceof ReplayResponseError) { + throw error; + } + throw new ReplayResponseError('invalid-origin-url'); + } +} + +export function replayRenderedResponseByteLength( + response: ReplayRenderedResponse +): number { + let total = response.body.byteLength; + for (const [name, values] of Object.entries(response.headers)) { + for (const value of values) { + total += + Buffer.byteLength(name) + Buffer.byteLength(value) + 4; + } + } + return total; } export function renderReplayResponse( diff --git a/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts index 6bc67b696..1fcd64af3 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-run.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines, @typescript-eslint/no-non-null-assertion -- Replay lifecycle contracts share one typed scenario harness whose asserted values are created in the same test. */ import { + REPLAY_MAX_PENDING_BARRIER_BYTES, REPLAY_MAX_REQUESTS, REPLAY_RUN_HARD_LIFETIME_MS, REPLAY_RUN_INACTIVITY_MS, @@ -7,6 +8,7 @@ import { import { createReplayRun, ReplayFinalizeResult, + ReplayRun, ReplayRunError, } from './replay-run.js'; import { @@ -470,6 +472,29 @@ describe('ReplayRun lifecycle and ledger', () => { run.dispose(); }); + it('falls through a saturated unordered matcher to an eligible identical matcher', async () => { + const first = replayExpectation('first'); + const second = replayExpectation('second'); + first.request.query.exact = { action: 'shared' }; + second.request.query.exact = { action: 'shared' }; + const run = createReplayRun(replayFixture([first, second]), { + runId: 'unordered-saturation', + }); + + await run.request(observedRequest('shared')); + await run.request(observedRequest('shared')); + + expect(run.finalize()).toMatchObject({ + ok: true, + ledger: { + operationCounts: { first: 1, second: 1 }, + mismatchCounts: {}, + terminalState: 'complete', + }, + }); + run.dispose(); + }); + it('erases symbols and rejects requests after disposal', async () => { const run = createReplayRun( replayFixture([replayExpectation('profile')]), @@ -526,6 +551,154 @@ describe('ReplayRun lifecycle and ledger', () => { }); }); +describe('ReplayRun scheduled expiry and retained-byte defense', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('expires and rejects a held barrier after inactivity without another API call', async () => { + jest.useFakeTimers({ now: 1_000 }); + const run = createReplayRun( + replayFixture( + [replayExpectation('held'), replayExpectation('never')], + { + barrier: { + name: 'inactivity-expiry', + releaseWhenMatched: 2, + }, + } + ), + { runId: 'scheduled-inactivity' } + ); + + try { + const pending = run.request(observedRequest('held')); + const outcome = pending.then( + () => undefined, + (error: ReplayRunError) => error + ); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(REPLAY_RUN_INACTIVITY_MS); + + await expect(outcome).resolves.toMatchObject({ + code: 'run-expired', + }); + expect(run.getLedger().mismatchCounts).toEqual({ + 'run-expired': 1, + }); + expect(jest.getTimerCount()).toBe(0); + } finally { + run.dispose(); + } + }); + + it('enforces the scheduled hard lifetime while activity keeps resetting inactivity', async () => { + jest.useFakeTimers({ now: 10_000 }); + const repeated = replayExpectation('repeat', { min: 6, max: 6 }); + const never = replayExpectation('never'); + const run = createReplayRun( + replayFixture([repeated, never], { + barrier: { + name: 'hard-expiry', + releaseWhenMatched: 7, + }, + }), + { runId: 'scheduled-hard-expiry' } + ); + + try { + const pending = [run.request(observedRequest('repeat'))]; + const settled = pending.map((request) => + request.then( + () => ({ status: 'fulfilled' as const }), + (reason: ReplayRunError) => ({ + status: 'rejected' as const, + reason, + }) + ) + ); + expect(jest.getTimerCount()).toBe(1); + for (let index = 1; index < 6; index += 1) { + await jest.advanceTimersByTimeAsync( + REPLAY_RUN_INACTIVITY_MS - 1 + ); + const request = run.request(observedRequest('repeat')); + pending.push(request); + settled.push( + request.then( + () => ({ status: 'fulfilled' as const }), + (reason: ReplayRunError) => ({ + status: 'rejected' as const, + reason, + }) + ) + ); + } + await jest.advanceTimersByTimeAsync( + REPLAY_RUN_HARD_LIFETIME_MS - + 5 * (REPLAY_RUN_INACTIVITY_MS - 1) + ); + + expect(await Promise.all(settled)).toEqual( + Array.from({ length: 6 }, () => + expect.objectContaining({ + status: 'rejected', + reason: expect.objectContaining({ + code: 'run-expired', + }), + }) + ) + ); + expect(jest.getTimerCount()).toBe(0); + } finally { + run.dispose(); + } + }); + + it('rejects a barrier that exceeds the runtime retained-byte cap', async () => { + const repeated = replayExpectation('large', { + min: 2, + max: 2, + responseBody: { + kind: 'generated', + byteLength: REPLAY_MAX_PENDING_BARRIER_BYTES / 2, + byte: 97, + }, + }); + const run = new ReplayRun( + replayFixture([repeated], { + barrier: { + name: 'runtime-cap', + releaseWhenMatched: 2, + }, + }), + { runId: 'runtime-barrier-cap' } + ); + const first = run.request(observedRequest('large')); + const firstOutcome = first.then( + () => undefined, + (error: ReplayRunError) => error + ); + + try { + await expect( + run.request(observedRequest('large')) + ).rejects.toMatchObject({ + code: 'pending-barrier-bytes-exceeded', + }); + expect(run.getLedger().mismatchCounts).toEqual({ + 'pending-barrier-bytes-exceeded': 1, + }); + } finally { + run.dispose(); + } + await expect(firstOutcome).resolves.toMatchObject({ + code: 'run-disposed', + }); + }); +}); + describe('ReplayRun response rendering', () => { it('reuses one generated symbol when a response expectation repeats', async () => { const expected = replayExpectation('repeat-generated', { diff --git a/apps/stalker-mock-server/src/app/replay/replay-run.ts b/apps/stalker-mock-server/src/app/replay/replay-run.ts index 724c52741..961faaa8b 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-run.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-run.ts @@ -1,4 +1,6 @@ +/* eslint-disable max-lines -- Replay lifecycle, expiry, matching, and retained-response accounting form one security-sensitive state machine. */ import { + REPLAY_MAX_PENDING_BARRIER_BYTES, REPLAY_MAX_REQUESTS, REPLAY_RUN_HARD_LIFETIME_MS, REPLAY_RUN_INACTIVITY_MS, @@ -7,7 +9,10 @@ import { matchReplayRequest, ReplayMismatchCode, } from './replay-request-matcher.js'; -import { renderReplayResponse } from './replay-response.js'; +import { + renderReplayResponse, + replayRenderedResponseByteLength, +} from './replay-response.js'; import { parseReplayFixture } from './replay-schema.js'; import { createReplayRunId, ReplaySymbolTable } from './replay-symbols.js'; import { @@ -21,6 +26,7 @@ export type ReplayRunErrorCode = | ReplayMismatchCode | 'unexpected-request' | 'cardinality-exceeded' + | 'pending-barrier-bytes-exceeded' | 'request-limit-exceeded' | 'run-expired' | 'run-finalized' @@ -64,8 +70,18 @@ export interface CreateReplayRunOptions { runId?: string; now?: () => number; originUrls?: Readonly>; + onExpired?: () => void; } +export type ReplayNetworkMismatchCode = + | 'invalid-replay-route' + | 'invalid-request-method' + | 'invalid-request-body' + | 'request-body-too-large' + | 'request-body-timeout' + | 'response-send-failed' + | 'replay-internal-error'; + interface PendingBarrierResponse { response: ReplayRenderedResponse; resolve: (response: ReplayRenderedResponse) => void; @@ -86,6 +102,7 @@ export class ReplayRun { private readonly symbols: ReplaySymbolTable; private readonly now: () => number; private readonly originUrls: Readonly>; + private readonly onExpired?: () => void; private readonly createdAt: number; private lastActivityAt: number; private phaseIndex = 0; @@ -95,6 +112,8 @@ export class ReplayRun { private readonly operationCounts = new Map(); private readonly mismatchCounts = new Map(); private readonly pendingBarrierResponses: PendingBarrierResponse[] = []; + private pendingBarrierBytes = 0; + private expiryTimer?: ReturnType; private barrierReleased = false; private hadUnexpectedRequest = false; private expectedEndpointReached = false; @@ -109,6 +128,7 @@ export class ReplayRun { this.runId = options.runId ?? createReplayRunId(); this.now = options.now ?? Date.now; this.originUrls = options.originUrls ?? {}; + this.onExpired = options.onExpired; this.createdAt = this.now(); this.lastActivityAt = this.createdAt; this.state = fixture.initialState; @@ -118,6 +138,7 @@ export class ReplayRun { this.expectationCounts.set(expectation.id, 0); } } + this.scheduleExpiry(); } getGeneratedInputs(): Readonly> { @@ -151,6 +172,7 @@ export class ReplayRun { } this.requestCount += 1; this.lastActivityAt = this.now(); + this.scheduleExpiry(); const match = this.findMatch(observed); if (match === undefined) { @@ -185,6 +207,17 @@ export class ReplayRun { const barrier = phase?.barrier; if (barrier !== undefined && !this.barrierReleased) { + const responseBytes = + replayRenderedResponseByteLength(response); + if ( + this.pendingBarrierBytes + responseBytes > + REPLAY_MAX_PENDING_BARRIER_BYTES + ) { + return this.rejectUnexpected( + 'pending-barrier-bytes-exceeded' + ); + } + this.pendingBarrierBytes += responseBytes; const held = new Promise( (resolve, reject) => { this.pendingBarrierResponses.push({ @@ -202,6 +235,7 @@ export class ReplayRun { if (phaseMatches >= barrier.releaseWhenMatched) { this.barrierReleased = true; const pending = this.pendingBarrierResponses.splice(0); + this.pendingBarrierBytes = 0; pending.forEach((item) => item.resolve(item.response)); } this.advanceCompletedPhase(); @@ -267,13 +301,43 @@ export class ReplayRun { return; } this.disposed = true; + this.clearExpiryTimer(); const pending = this.pendingBarrierResponses.splice(0); + this.pendingBarrierBytes = 0; pending.forEach((item) => item.reject(new ReplayRunError('run-disposed')) ); this.symbols.clear(); } + recordNetworkFailure( + code: ReplayNetworkMismatchCode, + requestAlreadyCounted = false + ): void { + if ( + this.disposed || + this.finalizedResult !== undefined || + this.expired + ) { + return; + } + this.expireIfNeeded(); + if (this.expired) { + return; + } + if (!requestAlreadyCounted) { + if (this.requestCount >= REPLAY_MAX_REQUESTS) { + this.recordMismatch('request-limit-exceeded'); + return; + } + this.requestCount += 1; + } + this.lastActivityAt = this.now(); + this.scheduleExpiry(); + this.hadUnexpectedRequest = true; + this.recordMismatch(code); + } + private assertActive(): void { if (this.disposed) { throw new ReplayRunError('run-disposed'); @@ -293,11 +357,54 @@ export class ReplayRun { current - this.lastActivityAt >= REPLAY_RUN_INACTIVITY_MS ) { this.expired = true; + this.clearExpiryTimer(); this.recordMismatch('run-expired'); const pending = this.pendingBarrierResponses.splice(0); + this.pendingBarrierBytes = 0; pending.forEach((item) => item.reject(new ReplayRunError('run-expired')) ); + try { + this.onExpired?.(); + } catch { + // Expiry remains authoritative even if listener cleanup fails. + } + } else { + this.scheduleExpiry(); + } + } + + private scheduleExpiry(): void { + this.clearExpiryTimer(); + if ( + this.disposed || + this.expired + ) { + return; + } + const current = this.now(); + const delay = Math.max( + 0, + Math.min( + this.createdAt + + REPLAY_RUN_HARD_LIFETIME_MS - + current, + this.lastActivityAt + + REPLAY_RUN_INACTIVITY_MS - + current + ) + ); + this.expiryTimer = setTimeout(() => { + this.expiryTimer = undefined; + this.expireIfNeeded(); + }, delay); + this.expiryTimer.unref?.(); + } + + private clearExpiryTimer(): void { + if (this.expiryTimer !== undefined) { + clearTimeout(this.expiryTimer); + this.expiryTimer = undefined; } } @@ -307,7 +414,11 @@ export class ReplayRun { return []; } if (phase.mode === 'unordered') { - return phase.expectations; + return phase.expectations.filter( + (expectation) => + (this.expectationCounts.get(expectation.id) ?? 0) < + expectation.cardinality.max + ); } const next = phase.expectations.find( (expectation) => @@ -351,7 +462,11 @@ export class ReplayRun { } private rejectUnexpected( - code: ReplayMismatchCode | 'unexpected-request' | 'cardinality-exceeded' + code: + | ReplayMismatchCode + | 'unexpected-request' + | 'cardinality-exceeded' + | 'pending-barrier-bytes-exceeded' ): never { this.hadUnexpectedRequest = true; this.recordMismatch(code); diff --git a/apps/stalker-mock-server/src/app/replay/replay-server.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-server.spec.ts index a653e5a96..a213de1d9 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-server.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-server.spec.ts @@ -4,7 +4,10 @@ import { createReplayServerRun, ReplayServerRun, } from './replay-server.js'; -import { REPLAY_MAX_REQUEST_BODY_BYTES } from './replay.constants.js'; +import { + REPLAY_MAX_REQUEST_BODY_BYTES, + REPLAY_RUN_INACTIVITY_MS, +} from './replay.constants.js'; import { ReplayExpectation, ReplayFixtureV1, @@ -281,16 +284,23 @@ describe('ReplayServerRun redirects and wire preservation', () => { await Promise.all(activeRuns.splice(0).map((run) => run.dispose())); }); - it('keeps an absolute-path redirect on the same synthetic run origin', async () => { + it('resolves a relative redirect under the same synthetic run prefix', async () => { const redirect = expectation('redirect', { path: '/entry', response: { status: 302, - headers: { location: ['/landing'] }, + headers: { location: ['landing?from=relative'] }, body: { kind: 'empty' }, }, }); - const landing = expectation('landing', { path: '/landing' }); + const landing = expectation('landing', { + path: '/landing', + query: { + exact: { from: 'relative' }, + present: [], + absent: [], + }, + }); const run = await createReplayServerRun( fixture(orderedPhases([redirect, landing]), { entry: { origin: 'portal', path: '/entry' }, @@ -302,7 +312,9 @@ describe('ReplayServerRun redirects and wire preservation', () => { const first = await fetch(run.entryUrl, { redirect: 'manual' }); const location = first.headers.get('location'); - expect(location).toBe(`${run.originUrls['portal']}/landing`); + expect(location).toBe( + `${run.originUrls['portal']}/landing?from=relative` + ); expect(new URL(location!).origin).toBe( new URL(run.entryUrl).origin ); @@ -311,7 +323,7 @@ describe('ReplayServerRun redirects and wire preservation', () => { expect(run.finalize().ok).toBe(true); }); - it('renders a typed named-origin redirect as a real cross-origin absolute URL', async () => { + it('renders typed user-info and auth query fields on a named-origin redirect', async () => { const redirect = expectation('redirect', { path: '/entry', response: { @@ -322,6 +334,27 @@ describe('ReplayServerRun redirects and wire preservation', () => { kind: 'origin-url', origin: 'auth', path: '/login', + userInfo: { + username: { + kind: 'ref', + symbol: 'username', + }, + password: { + kind: 'ref', + symbol: 'password', + }, + }, + query: { + login: { + kind: 'ref', + symbol: 'username', + }, + auth: { + kind: 'generate', + symbol: 'redirect-auth', + valueKind: 'token', + }, + }, }, ], }, @@ -331,12 +364,38 @@ describe('ReplayServerRun redirects and wire preservation', () => { const login = expectation('login', { origin: 'auth', path: '/login', + query: { + exact: { + login: { + kind: 'ref', + symbol: 'username', + }, + auth: { + kind: 'ref', + symbol: 'redirect-auth', + }, + }, + present: [], + absent: [], + }, }); const run = await createReplayServerRun( fixture(orderedPhases([redirect, login]), { origins: { portal: {}, auth: {} }, entry: { origin: 'portal', path: '/entry' }, expectedEndpoint: { origin: 'auth', path: '/login' }, + symbols: [ + { + kind: 'generate', + symbol: 'username', + valueKind: 'credential', + }, + { + kind: 'generate', + symbol: 'password', + valueKind: 'credential', + }, + ], }), { runId: 'run-cross-origin' } ); @@ -344,12 +403,25 @@ describe('ReplayServerRun redirects and wire preservation', () => { const first = await fetch(run.entryUrl, { redirect: 'manual' }); const location = first.headers.get('location'); - expect(location).toBe(`${run.originUrls['auth']}/login`); - expect(new URL(location!).origin).not.toBe( + const target = new URL(location!); + expect(`${target.origin}${target.pathname}`).toBe( + `${new URL(run.originUrls['auth']!).origin}${ + new URL(run.originUrls['auth']!).pathname + }/login` + ); + expect(target.origin).not.toBe( new URL(run.entryUrl).origin ); + expect(target.username).toBe(run.generatedInputs['username']); + expect(target.password).toBe(run.generatedInputs['password']); + expect(target.searchParams.get('login')).toBe( + run.generatedInputs['username'] + ); + expect(target.searchParams.get('auth')).toMatch( + /^test-token-[0-9a-f]+$/ + ); - await fetch(location!); + await rawRequest(location!); expect(run.finalize().ok).toBe(true); }); @@ -429,6 +501,83 @@ describe('ReplayServerRun redirects and wire preservation', () => { expect(run.finalize().ok).toBe(true); }); + it('preserves constructor and __proto__ fields through query, headers, cookies, and form parsing', async () => { + const exactQuery = Object.fromEntries([ + ['constructor', 'query-constructor'], + ['__proto__', 'query-prototype'], + ]); + const exactHeaders = Object.fromEntries([ + ['constructor', 'header-constructor'], + ['__proto__', 'header-prototype'], + ]); + const exactCookies = Object.fromEntries([ + ['constructor', 'cookie-constructor'], + ['__proto__', 'cookie-prototype'], + ]); + const exactForm = Object.fromEntries([ + ['constructor', 'form-constructor'], + ['__proto__', 'form-prototype'], + ]); + const request = expectation('prototype-fields', { + method: 'POST', + path: '/request', + query: { + exact: exactQuery, + present: [], + absent: [], + }, + headers: { + exact: exactHeaders, + present: [], + absent: [], + }, + cookies: { + exact: exactCookies, + present: [], + absent: [], + attributes: {}, + }, + body: { + kind: 'form', + exact: exactForm, + present: [], + absent: [], + }, + }); + const run = await createReplayServerRun( + fixture(orderedPhases([request]), { + entry: { origin: 'portal', path: '/request' }, + expectedEndpoint: { + origin: 'portal', + path: '/request', + }, + }), + { runId: 'run-prototype-fields' } + ); + activeRuns.push(run); + const headers = Object.fromEntries([ + ['content-type', 'application/x-www-form-urlencoded'], + ['constructor', 'header-constructor'], + ['__proto__', 'header-prototype'], + [ + 'cookie', + 'constructor=cookie-constructor; __proto__=cookie-prototype', + ], + ]); + + const response = await rawRequest( + `${run.entryUrl}?constructor=query-constructor&__proto__=query-prototype`, + { + method: 'POST', + headers, + body: 'constructor=form-constructor&__proto__=form-prototype', + } + ); + + expect(response.status).toBe(200); + expect(run.finalize().ok).toBe(true); + }); + it.each([ [ 'json', @@ -520,6 +669,12 @@ describe('ReplayServerRun redirects and wire preservation', () => { error: { code: 'invalid-request-body' }, }), }); + expect(run.finalize()).toMatchObject({ + ok: false, + ledger: { + mismatchCounts: { 'invalid-request-body': 1 }, + }, + }); }); it('requires duplicate cookie names to be matched through the raw repeated-header boundary', async () => { @@ -587,6 +742,12 @@ describe('ReplayServerRun redirects and wire preservation', () => { expect(JSON.parse(response.body)).toEqual({ error: { code: 'request-body-too-large' }, }); + expect(run.finalize()).toMatchObject({ + ok: false, + ledger: { + mismatchCounts: { 'request-body-too-large': 1 }, + }, + }); }); it('rejects a chunked body as soon as it crosses the cap without waiting for request end', async () => { @@ -620,6 +781,12 @@ describe('ReplayServerRun redirects and wire preservation', () => { error: { code: 'request-body-too-large' }, }), }); + expect(run.finalize()).toMatchObject({ + ok: false, + ledger: { + mismatchCounts: { 'request-body-too-large': 1 }, + }, + }); stream.request.destroy(); }); @@ -655,11 +822,205 @@ describe('ReplayServerRun redirects and wire preservation', () => { error: { code: 'request-body-timeout' }, }), }); + expect(run.finalize()).toMatchObject({ + ok: false, + ledger: { + mismatchCounts: { 'request-body-timeout': 1 }, + }, + }); stream.request.destroy(); }); + + it('records a wrong-route request after a successful expected request', async () => { + const run = await createReplayServerRun( + fixture( + orderedPhases([ + expectation('expected', { path: '/expected' }), + ]), + { + entry: { origin: 'portal', path: '/expected' }, + } + ), + { runId: 'run-wrong-route' } + ); + activeRuns.push(run); + + await expect(fetch(run.entryUrl)).resolves.toMatchObject({ + status: 200, + }); + const outside = new URL(run.entryUrl); + outside.pathname = '/outside-replay-prefix'; + const response = await rawRequest(outside.href); + + expect(response).toMatchObject({ + status: 404, + body: JSON.stringify({ + error: { code: 'invalid-replay-route' }, + }), + }); + expect(run.finalize()).toMatchObject({ + ok: false, + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'unexpected-request' }), + ]), + ledger: { + operationCounts: { expected: 1 }, + mismatchCounts: { 'invalid-replay-route': 1 }, + terminalState: 'state-1', + }, + }); + }); + + it('records a disallowed HTTP method as unexpected network traffic', async () => { + const run = await createReplayServerRun( + fixture( + orderedPhases([ + expectation('expected', { path: '/expected' }), + ]), + { + entry: { origin: 'portal', path: '/expected' }, + } + ), + { runId: 'run-invalid-method' } + ); + activeRuns.push(run); + + const response = await rawRequest(run.entryUrl, { + method: 'TRACE', + }); + + expect(response).toMatchObject({ + status: 405, + body: JSON.stringify({ + error: { code: 'invalid-request-method' }, + }), + }); + expect(run.finalize()).toMatchObject({ + ok: false, + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'unexpected-request' }), + ]), + ledger: { + mismatchCounts: { 'invalid-request-method': 1 }, + }, + }); + }); + + it('records a deterministic response-send failure without double-counting the request', async () => { + const request = expectation('invalid-response', { + path: '/request', + response: { + status: 200, + headers: { + 'x-invalid': ['line-one\r\nline-two'], + }, + body: { kind: 'empty' }, + }, + }); + const run = await createReplayServerRun( + fixture(orderedPhases([request]), { + entry: { origin: 'portal', path: '/request' }, + expectedEndpoint: { + origin: 'portal', + path: '/request', + }, + }), + { runId: 'run-response-send-failure' } + ); + activeRuns.push(run); + + const response = await rawRequest(run.entryUrl); + + expect(response).toMatchObject({ + status: 500, + body: JSON.stringify({ + error: { code: 'response-send-failed' }, + }), + }); + expect(run.finalize()).toMatchObject({ + ok: false, + ledger: { + operationCounts: { 'invalid-response': 1 }, + mismatchCounts: { 'response-send-failed': 1 }, + terminalState: 'state-1', + }, + }); + }); }); describe('ReplayServerRun lifecycle', () => { + it('expires a held request and closes its listener without another control call', async () => { + const realSetImmediate = setImmediate; + jest.useFakeTimers({ now: 25_000 }); + let run: ReplayServerRun | undefined; + try { + const held = expectation('held', { path: '/held' }); + const never = expectation('never', { path: '/never' }); + run = await createReplayServerRun( + fixture( + [ + { + name: 'expiring-barrier', + state: 'start', + nextState: 'complete', + mode: 'unordered', + barrier: { + name: 'two-requests', + releaseWhenMatched: 2, + }, + expectations: [held, never], + }, + ], + { + entry: { origin: 'portal', path: '/held' }, + expectedEndpoint: { + origin: 'portal', + path: '/held', + }, + } + ), + { runId: 'run-scheduled-expiry' } + ); + const heldResponse = rawRequest(run.entryUrl, { + headers: { connection: 'close' }, + }); + const heldOutcome = heldResponse.then( + (response) => response, + (error: Error) => error + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (run.getLedger().operationCounts['held'] === 1) { + break; + } + await new Promise((resolve) => + realSetImmediate(resolve) + ); + } + expect(run.getLedger().operationCounts['held']).toBe(1); + + await jest.advanceTimersByTimeAsync( + REPLAY_RUN_INACTIVITY_MS + ); + + await expect(heldOutcome).resolves.toMatchObject({ + status: 410, + body: JSON.stringify({ + error: { code: 'run-expired' }, + }), + }); + await expect(rawRequest(run.entryUrl)).rejects.toThrow(); + expect(run.finalize()).toMatchObject({ + ok: false, + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'run-expired' }), + ]), + }); + } finally { + await run?.dispose(); + jest.useRealTimers(); + } + }); + it('disposal rejects held barriers and closes every listener', async () => { const held = expectation('held', { path: '/held' }); const never = expectation('never', { path: '/never' }); diff --git a/apps/stalker-mock-server/src/app/replay/replay-server.ts b/apps/stalker-mock-server/src/app/replay/replay-server.ts index b76644443..d0dfc1b26 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-server.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-server.ts @@ -4,6 +4,8 @@ import http, { IncomingMessage, Server, ServerResponse, + validateHeaderName, + validateHeaderValue, } from 'node:http'; import { AddressInfo } from 'node:net'; import { @@ -43,6 +45,7 @@ const NETWORK_ERROR_CODE = { INVALID_ROUTE: 'invalid-replay-route', REQUEST_BODY_TOO_LARGE: 'request-body-too-large', REQUEST_BODY_TIMEOUT: 'request-body-timeout', + RESPONSE_SEND_FAILED: 'response-send-failed', } as const; type ReplayNetworkErrorCode = @@ -51,7 +54,8 @@ type ReplayNetworkErrorCode = class ReplayNetworkError extends Error { constructor( readonly code: ReplayNetworkErrorCode, - readonly status: number + readonly status: number, + readonly requestAlreadyCounted = false ) { super(`Replay network request rejected: ${code}.`); this.name = 'ReplayNetworkError'; @@ -84,14 +88,25 @@ function sendError( status: number, code: string ): void { + if (response.destroyed) { + return; + } + if (response.headersSent) { + response.destroy(); + return; + } const body = Buffer.from(JSON.stringify({ error: { code } })); - response.writeHead(status, { - 'content-type': 'application/json', - 'content-length': String(body.byteLength), - 'cache-control': 'no-store', - connection: 'close', - }); - response.end(body); + try { + response.writeHead(status, { + 'content-type': 'application/json', + 'content-length': String(body.byteLength), + 'cache-control': 'no-store', + connection: 'close', + }); + response.end(body); + } catch { + response.destroy(); + } } function statusForRunError(error: ReplayRunError): number { @@ -121,7 +136,15 @@ function requestPath( if (requestUrl === undefined) { throw new ReplayNetworkError(NETWORK_ERROR_CODE.INVALID_ROUTE, 404); } - const url = new URL(requestUrl, 'http://replay.invalid'); + let url: URL; + try { + url = new URL(requestUrl, 'http://replay.invalid'); + } catch { + throw new ReplayNetworkError( + NETWORK_ERROR_CODE.INVALID_ROUTE, + 404 + ); + } const path = url.pathname === routePrefix ? '/' @@ -135,7 +158,7 @@ function requestPath( } function repeatedQuery(url: URL): Record { - const query: Record = {}; + const query = Object.create(null) as Record; for (const [name, value] of url.searchParams) { (query[name] ??= []).push(value); } @@ -143,7 +166,7 @@ function repeatedQuery(url: URL): Record { } function repeatedHeaders(request: IncomingMessage): Record { - const headers: Record = {}; + const headers = Object.create(null) as Record; for (let index = 0; index < request.rawHeaders.length; index += 2) { const name = request.rawHeaders[index]?.toLowerCase(); const value = request.rawHeaders[index + 1]; @@ -162,7 +185,10 @@ function parseCookies( : headers.cookie === undefined ? [] : [headers.cookie]; - const cookies: Record = {}; + const cookies = Object.create(null) as Record< + string, + ReplayObservedCookie + >; const duplicateNames = new Set(); for (const header of cookieHeaders) { for (const pair of header.split(';')) { @@ -342,7 +368,7 @@ function parseBody( } } if (type === 'application/x-www-form-urlencoded') { - const value: Record = {}; + const value = Object.create(null) as Record; for (const [name, fieldValue] of new URLSearchParams(text)) { value[name] = fieldValue; } @@ -370,18 +396,107 @@ async function observeRequest( }; } +function staysWithinRunPrefix(target: URL, originUrl: string): boolean { + const declared = new URL(originUrl); + return ( + target.origin === declared.origin && + (target.pathname === declared.pathname || + target.pathname.startsWith(`${declared.pathname}/`)) + ); +} + +function hasControlOrBackslash(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return character === '\\' || code <= 31 || code === 127; + }); +} + +function hasParentLocationSegment(value: string): boolean { + const pathPart = value.split(/[?#]/, 1)[0] ?? ''; + return pathPart.split('/').some((segment) => { + try { + return decodeURIComponent(segment).toLowerCase() === '..'; + } catch { + return true; + } + }); +} + +function routeLocation( + value: string, + currentOriginUrl: string, + requestUrl: string | undefined, + originUrls: Readonly> +): string { + if ( + value.trim() !== value || + hasControlOrBackslash(value) || + hasParentLocationSegment(value) + ) { + throw new ReplayNetworkError( + NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED, + 500, + true + ); + } + try { + const currentBase = new URL(currentOriginUrl); + let target: URL; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) { + target = new URL(value); + } else if (value.startsWith('//')) { + throw new Error('scheme-relative location'); + } else if (value.startsWith('/')) { + const relative = new URL(value, currentBase.origin); + target = new URL(currentBase.href); + target.pathname = `${currentBase.pathname}${relative.pathname}`; + target.search = relative.search; + target.hash = relative.hash; + } else { + const incoming = new URL( + requestUrl ?? `${currentBase.pathname}/`, + currentBase.origin + ); + target = new URL(value, incoming); + } + if ( + !Object.values(originUrls).some((originUrl) => + staysWithinRunPrefix(target, originUrl) + ) + ) { + throw new Error('location escaped declared run origins'); + } + return target.href; + } catch (error) { + if (error instanceof ReplayNetworkError) { + throw error; + } + throw new ReplayNetworkError( + NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED, + 500, + true + ); + } +} + function routedHeaders( response: ReplayRenderedResponse, - currentOriginUrl: string + currentOriginUrl: string, + requestUrl: string | undefined, + originUrls: Readonly> ): Record { return Object.fromEntries( Object.entries(response.headers).map(([name, values]) => [ name, name === 'location' ? values.map((value) => - value.startsWith('/') - ? `${currentOriginUrl}${value}` - : value + routeLocation( + value, + currentOriginUrl, + requestUrl, + originUrls + ) ) : values, ]) @@ -391,13 +506,35 @@ function routedHeaders( function sendReplayResponse( response: ServerResponse, replay: ReplayRenderedResponse, - currentOriginUrl: string + currentOriginUrl: string, + requestUrl: string | undefined, + originUrls: Readonly> ): void { - response.writeHead( - replay.status, - routedHeaders(replay, currentOriginUrl) - ); - response.end(replay.body); + try { + const headers = routedHeaders( + replay, + currentOriginUrl, + requestUrl, + originUrls + ); + for (const [name, values] of Object.entries(headers)) { + validateHeaderName(name); + for (const value of values) { + validateHeaderValue(name, value); + } + } + response.writeHead(replay.status, headers); + response.end(replay.body); + } catch (error) { + if (error instanceof ReplayNetworkError) { + throw error; + } + throw new ReplayNetworkError( + NETWORK_ERROR_CODE.RESPONSE_SEND_FAILED, + 500, + true + ); + } } async function listen(server: Server): Promise { @@ -450,8 +587,15 @@ export async function createReplayServerRun( runId )}`; const listeners: ReplayListener[] = []; - const originUrls: Record = {}; + const originUrls = Object.create(null) as Record; const runtime: { run?: ReplayRun } = {}; + let closeListenersPromise: Promise | undefined; + const closeListeners = (): Promise => { + closeListenersPromise ??= Promise.all( + listeners.map(({ server }) => closeServer(server)) + ).then(() => undefined); + return closeListenersPromise; + }; try { for (const origin of Object.keys(fixture.origins)) { @@ -482,10 +626,16 @@ export async function createReplayServerRun( sendReplayResponse( response, rendered, - currentOriginUrl + currentOriginUrl, + request.url, + originUrls ); } catch (error) { if (error instanceof ReplayNetworkError) { + runtime.run?.recordNetworkFailure( + error.code, + error.requestAlreadyCounted + ); sendError(response, error.status, error.code); } else if (error instanceof ReplayRunError) { sendError( @@ -494,6 +644,9 @@ export async function createReplayServerRun( error.code ); } else { + runtime.run?.recordNetworkFailure( + NETWORK_ERROR_CODE.INTERNAL + ); sendError( response, 500, @@ -517,6 +670,9 @@ export async function createReplayServerRun( runId, now: options.now, originUrls, + onExpired: () => { + void closeListeners().catch(() => undefined); + }, }); runtime.run = run; @@ -531,9 +687,7 @@ export async function createReplayServerRun( dispose: () => { disposePromise ??= (async () => { run.dispose(); - await Promise.all( - listeners.map(({ server }) => closeServer(server)) - ); + await closeListeners(); })(); return disposePromise; }, diff --git a/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.spec.ts b/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.spec.ts index 13dbb9ae4..277dd1155 100644 --- a/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.spec.ts +++ b/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.spec.ts @@ -3,6 +3,8 @@ import { REPLAY_MAX_EXPECTATIONS, REPLAY_MAX_FIXTURE_BYTES, REPLAY_MAX_GENERATED_BODY_BYTES, + REPLAY_MAX_ORIGINS, + REPLAY_MAX_PENDING_BARRIER_BYTES, REPLAY_MAX_PHASES, REPLAY_MAX_REQUESTS, REPLAY_RUN_HARD_LIFETIME_MS, @@ -227,6 +229,30 @@ describe('replay fixture schema v1', () => { kind: 'origin-url', origin: 'auth', path: '/login', + userInfo: { + username: { + kind: 'ref', + symbol: 'credential', + }, + password: { + kind: 'generate', + symbol: 'redirect-password', + valueKind: 'credential', + }, + }, + query: { + username: { + kind: 'ref', + symbol: 'credential', + }, + token: [ + { + kind: 'ref', + symbol: 'token', + }, + 'synthetic-second-value', + ], + }, }, ], }; @@ -243,10 +269,55 @@ describe('replay fixture schema v1', () => { kind: 'origin-url', origin: 'auth', path: '/login', + userInfo: { + username: { + kind: 'ref', + symbol: 'credential', + }, + password: { + kind: 'generate', + symbol: 'redirect-password', + valueKind: 'credential', + }, + }, + query: { + username: { + kind: 'ref', + symbol: 'credential', + }, + token: [ + { + kind: 'ref', + symbol: 'token', + }, + 'synthetic-second-value', + ], + }, }, ]); }); + it.each([ + ['leading OWS', ' \t//external.invalid/path'], + ['trailing OWS', '/landing '], + ['control characters', '/landing\u0000'], + ['backslash authority', '\\\\external.invalid/path'], + ['scheme-relative authority', '//external.invalid/path'], + ['absolute scheme', 'HTTP://external.invalid/path'], + ['parent dot segment', '../landing'], + ['encoded parent dot segment', '%2e%2e/landing'], + ])('rejects a literal Location containing %s', (_label, location) => { + const value = fixture(); + const response = ( + (value['phases'] as Record[])[0]![ + 'expectations' + ] as Record[] + )[0]!['response'] as Record; + response['headers'] = { location: [location] }; + + expectSchemaCode(value, 'invalid-redirect-location'); + }); + it('rejects unknown or free-form absolute redirect origins', () => { const unknownOrigin = fixture(); const unknownResponse = ( @@ -459,10 +530,12 @@ describe('replay fixture schema v1', () => { describe('replay fixture limits', () => { it('owns the exact runtime limits', () => { expect(REPLAY_MAX_FIXTURE_BYTES).toBe(1024 * 1024); + expect(REPLAY_MAX_ORIGINS).toBe(8); expect(REPLAY_MAX_PHASES).toBe(128); expect(REPLAY_MAX_EXPECTATIONS).toBe(512); expect(REPLAY_MAX_REQUESTS).toBe(512); expect(REPLAY_MAX_GENERATED_BODY_BYTES).toBe(16 * 1024 * 1024); + expect(REPLAY_MAX_PENDING_BARRIER_BYTES).toBe(32 * 1024 * 1024); expect(REPLAY_RUN_HARD_LIFETIME_MS).toBe(10 * 60 * 1000); expect(REPLAY_RUN_INACTIVITY_MS).toBe(2 * 60 * 1000); }); @@ -544,4 +617,107 @@ describe('replay fixture limits', () => { }; expectSchemaCode(value, 'generated-body-too-large'); }); + + it('accepts eight named origins and rejects a ninth', () => { + const value = fixture(); + value['origins'] = Object.fromEntries( + Array.from({ length: 8 }, (_, index) => [`origin-${index}`, {}]) + ); + value['entry'] = { origin: 'origin-0', path: '/c/' }; + value['expectedEndpoint'] = { + origin: 'origin-0', + path: '/portal.php', + }; + for (const phase of value['phases'] as Record[]) { + for (const item of phase['expectations'] as Record< + string, + unknown + >[]) { + item['origin'] = 'origin-0'; + } + } + + expect(() => parseReplayFixture(value)).not.toThrow(); + (value['origins'] as Record)['origin-8'] = {}; + expectSchemaCode(value, 'too-many-origins'); + }); + + it('rejects a barrier whose generated responses can retain more than 32 MiB', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + phase['barrier'] = { + name: 'oversized-generated-barrier', + releaseWhenMatched: 3, + }; + phase['expectations'] = Array.from({ length: 3 }, (_, index) => + expectation( + `generated-${index}`, + { kind: 'absent' }, + { + kind: 'generated', + byteLength: 16 * 1024 * 1024, + byte: index, + } + ) + ); + + expectSchemaCode(value, 'pending-barrier-too-large'); + }); + + it('counts regular response bodies and headers in the pending barrier budget', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + const repeated = expectation( + 'regular-response', + { kind: 'absent' }, + { + kind: 'text', + value: 'x'.repeat(192 * 1024), + } + ); + repeated['cardinality'] = { min: 128, max: 128 }; + ( + repeated['response'] as Record + )['headers'] = { + 'x-replay-padding': ['y'.repeat(96 * 1024)], + }; + phase['barrier'] = { + name: 'oversized-regular-barrier', + releaseWhenMatched: 128, + }; + phase['expectations'] = [repeated]; + + expectSchemaCode(value, 'pending-barrier-too-large'); + }); + + it('counts JSON escaping expansion in the pending barrier budget', () => { + const value = fixture(); + const phase = (value['phases'] as Record[])[0]!; + const repeated = expectation( + 'escaped-json-response', + { kind: 'absent' }, + { + kind: 'json', + value: { + payload: { + kind: 'parts', + parts: [ + { + kind: 'literal', + value: '\u0001'.repeat(150 * 1024), + }, + ], + }, + }, + } + ); + repeated['cardinality'] = { min: 40, max: 40 }; + phase['barrier'] = { + name: 'escaped-json-barrier', + releaseWhenMatched: 40, + }; + phase['expectations'] = [repeated]; + + expectSchemaCode(value, 'pending-barrier-too-large'); + }); }); diff --git a/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.ts b/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.ts index 8723f3048..dad4e8520 100644 --- a/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.ts +++ b/libs/portal/stalker/replay-fixtures/src/lib/replay-schema.ts @@ -5,6 +5,8 @@ import { REPLAY_MAX_EXPECTATIONS, REPLAY_MAX_FIXTURE_BYTES, REPLAY_MAX_GENERATED_BODY_BYTES, + REPLAY_MAX_ORIGINS, + REPLAY_MAX_PENDING_BARRIER_BYTES, REPLAY_MAX_PHASES, REPLAY_MAX_REQUESTS, REPLAY_SCHEMA_VERSION, @@ -47,6 +49,16 @@ const SAFE_ORIGIN = /^[a-z][a-z0-9-]{0,31}$/; const LOWER_CASE_HEADER = /^[a-z0-9!#$%&'*+.^_`|~-]+$/; const JSONP_CALLBACK = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; const FREE_FORM_INTERPOLATION = /\$\{|{{|<%|%\{/; +const LOCATION_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const MAX_RENDERED_SYMBOL_BYTES = 128; +const MAX_RENDERED_ORIGIN_BASE_BYTES = 256; + +function hasLocationControlOrBackslash(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return character === '\\' || code <= 31 || code === 127; + }); +} function issue( context: ValidationContext, @@ -703,6 +715,204 @@ function validateRequestBody( return false; } +function hasParentLocationSegment(value: string): boolean { + const path = value.split(/[?#]/, 1)[0] ?? ''; + return path.split('/').some((segment) => { + try { + return decodeURIComponent(segment) === '..'; + } catch { + return true; + } + }); +} + +function validateLiteralLocation( + value: unknown, + path: string, + context: ValidationContext +): value is string { + if ( + typeof value !== 'string' || + value.startsWith(' ') || + value.startsWith('\t') || + value.endsWith(' ') || + value.endsWith('\t') || + hasLocationControlOrBackslash(value) || + LOCATION_SCHEME.test(value) || + value.startsWith('//') || + hasParentLocationSegment(value) + ) { + issue( + context, + 'invalid-redirect-location', + path, + 'Literal redirects must be clean same-origin relative references without parent traversal.' + ); + return false; + } + + try { + const resolved = new URL(value, 'http://replay.invalid/request'); + if ( + resolved.origin !== 'http://replay.invalid' || + resolved.username.length > 0 || + resolved.password.length > 0 + ) { + throw new Error('cross-origin redirect'); + } + } catch { + issue( + context, + 'invalid-redirect-location', + path, + 'Literal redirect is not a safe same-origin relative reference.' + ); + return false; + } + return true; +} + +function validateOriginUrlQuery( + value: unknown, + path: string, + context: ValidationContext +): boolean { + if (!isRecord(value)) { + issue( + context, + 'invalid-redirect-location', + path, + 'Named-origin query fields must be an object.' + ); + return false; + } + + let valid = true; + for (const [name, queryValue] of Object.entries(value)) { + if ( + name.length === 0 || + name.length > 256 || + hasLocationControlOrBackslash(name) || + FREE_FORM_INTERPOLATION.test(name) + ) { + issue( + context, + 'invalid-redirect-location', + `${path}.${name}`, + 'Named-origin query keys must be bounded literal names.' + ); + valid = false; + continue; + } + const values = Array.isArray(queryValue) ? queryValue : [queryValue]; + if (values.length === 0) { + issue( + context, + 'invalid-redirect-location', + `${path}.${name}`, + 'Named-origin query arrays cannot be empty.' + ); + valid = false; + continue; + } + values.forEach((item, index) => { + valid = + validateTemplateString( + item, + `${path}.${name}[${index}]`, + context, + true + ) && valid; + }); + } + return valid; +} + +function validateOriginUrlNode( + value: Record, + path: string, + context: ValidationContext, + origins: Set +): boolean { + if ( + !hasExactKeys(value, [ + 'kind', + 'origin', + 'path', + 'userInfo', + 'query', + ]) + ) { + issue( + context, + 'invalid-redirect-location', + path, + 'Named-origin URL node contains unknown fields.' + ); + return false; + } + + let valid = true; + if ( + typeof value['origin'] !== 'string' || + !origins.has(value['origin']) + ) { + issue( + context, + 'unknown-origin', + `${path}.origin`, + 'Redirect origin must reference a declared named origin.' + ); + valid = false; + } + valid = + validatePath(value['path'], `${path}.path`, context) && + valid; + + if (value['userInfo'] !== undefined) { + const userInfo = value['userInfo']; + if ( + !isRecord(userInfo) || + !hasExactKeys(userInfo, ['username', 'password']) || + !Object.prototype.hasOwnProperty.call(userInfo, 'username') + ) { + issue( + context, + 'invalid-redirect-location', + `${path}.userInfo`, + 'Named-origin user info requires a typed username and optional password.' + ); + valid = false; + } else { + valid = + validateTemplateString( + userInfo['username'], + `${path}.userInfo.username`, + context, + true + ) && valid; + if (userInfo['password'] !== undefined) { + valid = + validateTemplateString( + userInfo['password'], + `${path}.userInfo.password`, + context, + true + ) && valid; + } + } + } + if (value['query'] !== undefined) { + valid = + validateOriginUrlQuery( + value['query'], + `${path}.query`, + context + ) && valid; + } + return valid; +} + function validateResponseHeaders( value: unknown, path: string, @@ -741,10 +951,7 @@ function validateResponseHeaders( isRecord(headerValue) && headerValue['kind'] === 'origin-url' ) { - if ( - name !== 'location' || - !hasExactKeys(headerValue, ['kind', 'origin', 'path']) - ) { + if (name !== 'location') { issue( context, 'invalid-redirect-location', @@ -754,38 +961,17 @@ function validateResponseHeaders( valid = false; return; } - if ( - typeof headerValue['origin'] !== 'string' || - !origins.has(headerValue['origin']) - ) { - issue( - context, - 'unknown-origin', - `${headerPath}.origin`, - 'Redirect origin must reference a declared named origin.' - ); - valid = false; - } valid = - validatePath( - headerValue['path'], - `${headerPath}.path`, - context + validateOriginUrlNode( + headerValue, + headerPath, + context, + origins ) && valid; return; } if (name === 'location') { - if ( - typeof headerValue !== 'string' || - /^[A-Za-z][A-Za-z0-9+.-]*:/.test(headerValue) || - headerValue.startsWith('//') - ) { - issue( - context, - 'invalid-redirect-location', - headerPath, - 'Cross-origin redirects require a typed named-origin URL node.' - ); + if (!validateLiteralLocation(headerValue, headerPath, context)) { valid = false; return; } @@ -939,6 +1125,265 @@ function validateResponse( ); } +function boundedByteSum(left: number, right: number): number { + return Math.min( + REPLAY_MAX_PENDING_BARRIER_BYTES + 1, + left + right + ); +} + +function renderedTemplateStringUpperBound(value: unknown): number { + if (typeof value === 'string') { + return Buffer.byteLength(value); + } + if (!isRecord(value)) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + if (value['kind'] === 'generate' || value['kind'] === 'ref') { + return MAX_RENDERED_SYMBOL_BYTES; + } + if (value['kind'] !== 'parts' || !Array.isArray(value['parts'])) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + return value['parts'].reduce((total, part) => { + if ( + isRecord(part) && + part['kind'] === 'literal' && + typeof part['value'] === 'string' + ) { + return boundedByteSum(total, Buffer.byteLength(part['value'])); + } + return boundedByteSum(total, MAX_RENDERED_SYMBOL_BYTES); + }, 0); +} + +function renderedJsonStringUpperBound(value: unknown): number { + if (typeof value === 'string') { + return Math.max(0, Buffer.byteLength(JSON.stringify(value)) - 2); + } + if (!isRecord(value)) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + if (value['kind'] === 'generate' || value['kind'] === 'ref') { + return MAX_RENDERED_SYMBOL_BYTES * 6; + } + if (value['kind'] !== 'parts' || !Array.isArray(value['parts'])) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + return value['parts'].reduce((total, part) => { + if ( + isRecord(part) && + part['kind'] === 'literal' && + typeof part['value'] === 'string' + ) { + return boundedByteSum( + total, + Math.max( + 0, + Buffer.byteLength(JSON.stringify(part['value'])) - 2 + ) + ); + } + return boundedByteSum(total, MAX_RENDERED_SYMBOL_BYTES * 6); + }, 0); +} + +function renderedTemplateJsonUpperBound( + value: unknown, + depth = 0 +): number { + if (depth > 64) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return Buffer.byteLength(JSON.stringify(value)); + } + if (Array.isArray(value)) { + return value.reduce( + (total, item, index) => + boundedByteSum( + boundedByteSum(total, index === 0 ? 0 : 1), + renderedTemplateJsonUpperBound(item, depth + 1) + ), + 2 + ); + } + if (!isRecord(value)) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + if ( + value['kind'] === 'generate' || + value['kind'] === 'ref' || + value['kind'] === 'parts' + ) { + return boundedByteSum( + renderedJsonStringUpperBound(value), + 2 + ); + } + return Object.entries(value).reduce( + (total, [key, item], index) => { + const keyBytes = Buffer.byteLength(JSON.stringify(key)); + const valueBytes = renderedTemplateJsonUpperBound( + item, + depth + 1 + ); + return boundedByteSum( + boundedByteSum( + boundedByteSum(total, index === 0 ? 0 : 1), + keyBytes + 1 + ), + valueBytes + ); + }, + 2 + ); +} + +function renderedOriginUrlUpperBound(value: Record): number { + let total = MAX_RENDERED_ORIGIN_BASE_BYTES; + total = boundedByteSum( + total, + typeof value['path'] === 'string' + ? Buffer.byteLength(value['path']) * 3 + : REPLAY_MAX_PENDING_BARRIER_BYTES + 1 + ); + if (isRecord(value['userInfo'])) { + total = boundedByteSum( + total, + renderedTemplateStringUpperBound(value['userInfo']['username']) * + 3 + + 2 + ); + if (value['userInfo']['password'] !== undefined) { + total = boundedByteSum( + total, + renderedTemplateStringUpperBound( + value['userInfo']['password'] + ) * + 3 + + 1 + ); + } + } + if (isRecord(value['query'])) { + for (const [name, queryValue] of Object.entries(value['query'])) { + const values = Array.isArray(queryValue) + ? queryValue + : [queryValue]; + for (const item of values) { + total = boundedByteSum( + total, + (Buffer.byteLength(name) + + renderedTemplateStringUpperBound(item)) * + 3 + + 2 + ); + } + } + } + return total; +} + +function renderedHeaderValueUpperBound(value: unknown): number { + return isRecord(value) && value['kind'] === 'origin-url' + ? renderedOriginUrlUpperBound(value) + : renderedTemplateStringUpperBound(value); +} + +function renderedResponseUpperBound(expectation: unknown): number { + if (!isRecord(expectation) || !isRecord(expectation['response'])) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + const response = expectation['response']; + let total = 0; + if (isRecord(response['headers'])) { + for (const [name, values] of Object.entries(response['headers'])) { + if (!Array.isArray(values)) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + for (const value of values) { + total = boundedByteSum( + total, + Buffer.byteLength(name) + + renderedHeaderValueUpperBound(value) + + 4 + ); + } + } + } + + const body = response['body']; + if (!isRecord(body)) { + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } + switch (body['kind']) { + case 'empty': + return total; + case 'generated': + return boundedByteSum( + total, + typeof body['byteLength'] === 'number' + ? body['byteLength'] + : REPLAY_MAX_PENDING_BARRIER_BYTES + 1 + ); + case 'text': + return boundedByteSum( + total, + renderedTemplateStringUpperBound(body['value']) + ); + case 'json': + return boundedByteSum( + total, + renderedTemplateJsonUpperBound(body['value']) + ); + case 'jsonp': + return boundedByteSum( + total, + (typeof body['callback'] === 'string' + ? Buffer.byteLength(body['callback']) + : REPLAY_MAX_PENDING_BARRIER_BYTES + 1) + + renderedTemplateJsonUpperBound(body['value']) + + 3 + ); + default: + return REPLAY_MAX_PENDING_BARRIER_BYTES + 1; + } +} + +function pendingBarrierUpperBound( + expectations: unknown[], + releaseWhenMatched: number +): number { + const responseSizes: number[] = []; + for (const expectation of expectations) { + if ( + !isRecord(expectation) || + !isRecord(expectation['cardinality']) || + !Number.isInteger(expectation['cardinality']['max']) + ) { + continue; + } + const cardinality = Math.min( + expectation['cardinality']['max'] as number, + REPLAY_MAX_REQUESTS + ); + const responseSize = renderedResponseUpperBound(expectation); + for (let index = 0; index < cardinality; index += 1) { + responseSizes.push(responseSize); + } + } + responseSizes.sort((left, right) => right - left); + return responseSizes + .slice(0, releaseWhenMatched) + .reduce(boundedByteSum, 0); +} + function validateExpectation( value: unknown, path: string, @@ -1186,6 +1631,14 @@ function validateFixture(value: unknown): ReplayFixtureV1 { 'At least one named origin is required.' ); } else { + if (Object.keys(value['origins']).length > REPLAY_MAX_ORIGINS) { + issue( + context, + 'too-many-origins', + '$.origins', + 'Scenario exceeds the eight-origin listener limit.' + ); + } for (const [name, origin] of Object.entries(value['origins'])) { if (!SAFE_ORIGIN.test(name)) { issue( @@ -1415,6 +1868,24 @@ function validateFixture(value: unknown): ReplayFixtureV1 { 'Barrier threshold must be reachable by minimum cardinality.' ); } + if ( + isRecord(barrier) && + Number.isInteger(barrier['releaseWhenMatched']) && + (barrier['releaseWhenMatched'] as number) >= 1 && + (barrier['releaseWhenMatched'] as number) <= + REPLAY_MAX_REQUESTS && + pendingBarrierUpperBound( + phase['expectations'], + barrier['releaseWhenMatched'] as number + ) > REPLAY_MAX_PENDING_BARRIER_BYTES + ) { + issue( + context, + 'pending-barrier-too-large', + `${phasePath}.barrier`, + 'Barrier can retain more than the bounded pending-response budget.' + ); + } } }); diff --git a/libs/portal/stalker/replay-fixtures/src/lib/replay.constants.ts b/libs/portal/stalker/replay-fixtures/src/lib/replay.constants.ts index c509e9ecc..965acdd3b 100644 --- a/libs/portal/stalker/replay-fixtures/src/lib/replay.constants.ts +++ b/libs/portal/stalker/replay-fixtures/src/lib/replay.constants.ts @@ -1,11 +1,13 @@ export const REPLAY_SCHEMA_VERSION = 1 as const; export const REPLAY_MAX_FIXTURE_BYTES = 1024 * 1024; +export const REPLAY_MAX_ORIGINS = 8; export const REPLAY_MAX_PHASES = 128; export const REPLAY_MAX_EXPECTATIONS = 512; export const REPLAY_MAX_REQUESTS = 512; export const REPLAY_MAX_REQUEST_BODY_BYTES = 1024 * 1024; export const REPLAY_MAX_GENERATED_BODY_BYTES = 16 * 1024 * 1024; +export const REPLAY_MAX_PENDING_BARRIER_BYTES = 32 * 1024 * 1024; export const REPLAY_RUN_HARD_LIFETIME_MS = 10 * 60 * 1000; export const REPLAY_RUN_INACTIVITY_MS = 2 * 60 * 1000; diff --git a/libs/portal/stalker/replay-fixtures/src/lib/replay.types.ts b/libs/portal/stalker/replay-fixtures/src/lib/replay.types.ts index c77be0fe5..4303a0789 100644 --- a/libs/portal/stalker/replay-fixtures/src/lib/replay.types.ts +++ b/libs/portal/stalker/replay-fixtures/src/lib/replay.types.ts @@ -34,10 +34,17 @@ export interface ReplayPartsNode { parts: Array; } +export interface ReplayOriginUrlUserInfo { + username: ReplayTemplateString; + password?: ReplayTemplateString; +} + export interface ReplayOriginUrlNode { kind: 'origin-url'; origin: string; path: string; + userInfo?: ReplayOriginUrlUserInfo; + query?: Record; } export type ReplayTemplateString =