fix(stalker): harden replay network boundaries

This commit is contained in:
4gray committed 2026-07-27 09:54:30 +02:00
1 parent f3acc2ce39
commit 375169a69b
11 files changed
+1819 -189

No files matched your search

@@ -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<void>((resolve) => {
releaseFixture = resolve;
});
const loadStarted = new Promise<void>((resolve) => {
markLoadStarted = resolve;
});
const control = await startReplayControlPlane({
capability: TEST_CAPABILITY,
repository: {
async loadFixture(): Promise<ReplayFixtureV1> {
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,
@@ -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<string> {
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<string, ReplayServerRun>();
let expectedHost = '';
let closing = false;
const inFlightHandlers = new Set<Promise<void>>();
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<void> => {
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;
},
@@ -9,7 +9,9 @@ import {
export type ReplayOriginUrlMap = Readonly<Record<string, string>>;
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(
@@ -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', {
@@ -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<Record<string, string>>;
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<Record<string, string>>;
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<string, number>();
private readonly mismatchCounts = new Map<string, number>();
private readonly pendingBarrierResponses: PendingBarrierResponse[] = [];
private pendingBarrierBytes = 0;
private expiryTimer?: ReturnType<typeof setTimeout>;
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<Record<string, string>> {
@@ -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<ReplayRenderedResponse>(
(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);
@@ -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<void>((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' });
@@ -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<string, string[]> {
const query: Record<string, string[]> = {};
const query = Object.create(null) as Record<string, string[]>;
for (const [name, value] of url.searchParams) {
(query[name] ??= []).push(value);
}
@@ -143,7 +166,7 @@ function repeatedQuery(url: URL): Record<string, string[]> {
}
function repeatedHeaders(request: IncomingMessage): Record<string, string[]> {
const headers: Record<string, string[]> = {};
const headers = Object.create(null) as Record<string, string[]>;
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<string, ReplayObservedCookie> = {};
const cookies = Object.create(null) as Record<
string,
ReplayObservedCookie
>;
const duplicateNames = new Set<string>();
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<string, string> = {};
const value = Object.create(null) as Record<string, string>;
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<Record<string, string>>
): 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<string, string>>
): Record<string, string[]> {
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<Record<string, string>>
): 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<number> {
@@ -450,8 +587,15 @@ export async function createReplayServerRun(
runId
)}`;
const listeners: ReplayListener[] = [];
const originUrls: Record<string, string> = {};
const originUrls = Object.create(null) as Record<string, string>;
const runtime: { run?: ReplayRun } = {};
let closeListenersPromise: Promise<void> | undefined;
const closeListeners = (): Promise<void> => {
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;
},
@@ -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<string, unknown>[])[0]![
'expectations'
] as Record<string, unknown>[]
)[0]!['response'] as Record<string, unknown>;
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<string, unknown>[]) {
for (const item of phase['expectations'] as Record<
string,
unknown
>[]) {
item['origin'] = 'origin-0';
}
}
expect(() => parseReplayFixture(value)).not.toThrow();
(value['origins'] as Record<string, unknown>)['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<string, unknown>[])[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<string, unknown>[])[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<string, unknown>
)['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<string, unknown>[])[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');
});
});
@@ -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<string, unknown>,
path: string,
context: ValidationContext,
origins: Set<string>
): 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<number>((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<number>((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<string, unknown>): 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.'
);
}
}
});
@@ -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;
@@ -34,10 +34,17 @@ export interface ReplayPartsNode {
parts: Array<ReplayLiteralPart | ReplayRefNode>;
}
export interface ReplayOriginUrlUserInfo {
username: ReplayTemplateString;
password?: ReplayTemplateString;
}
export interface ReplayOriginUrlNode {
kind: 'origin-url';
origin: string;
path: string;
userInfo?: ReplayOriginUrlUserInfo;
query?: Record<string, ReplayTemplateString | ReplayTemplateString[]>;
}
export type ReplayTemplateString =