Skip to content

Commit b99c3f7

Browse files
harden failure bundle artifact downloads
1 parent 15e95de commit b99c3f7

2 files changed

Lines changed: 205 additions & 8 deletions

File tree

src/lib/bundle.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* the full http+fetch path is wired against MSW).
99
*/
1010

11-
import { mkdtempSync } from 'node:fs';
11+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
1212
import { tmpdir } from 'node:os';
1313
import { join } from 'node:path';
1414
import { describe, expect, it } from 'vitest';
@@ -19,10 +19,12 @@ import {
1919
buildMeta,
2020
pickCodeExtension,
2121
resolveBundleDir,
22+
STREAM_URL_MAX_BYTES,
2223
STREAM_URL_MAX_RETRIES,
2324
streamUrlToFile,
2425
stepFilenamePrefix,
2526
type AssertContextIntegrityOptions,
27+
writeBundle,
2628
} from './bundle.js';
2729
import type { CliFailureContext } from '../commands/test.js';
2830

@@ -548,6 +550,15 @@ describe('stepFilenamePrefix', () => {
548550
expect(stepFilenamePrefix(100)).toBe('100');
549551
expect(stepFilenamePrefix(999)).toBe('999');
550552
});
553+
554+
it('rejects non-numeric runtime values before they become path segments', () => {
555+
expect(() => stepFilenamePrefix('../escape' as unknown as number)).toThrowError(
556+
expect.objectContaining({
557+
code: 'VALIDATION_ERROR',
558+
details: expect.objectContaining({ field: 'steps[].stepIndex' }),
559+
}),
560+
);
561+
});
551562
});
552563

553564
describe('buildMeta', () => {
@@ -667,6 +678,49 @@ describe('streamUrlToFile retry', () => {
667678
expect(calls).toBe(1);
668679
});
669680

681+
it('rejects local artifact URLs before fetch is called', async () => {
682+
let calls = 0;
683+
const fetchImpl = async () => {
684+
calls++;
685+
return new Response('private', { status: 200 });
686+
};
687+
await expect(
688+
streamUrlToFile(
689+
'http://127.0.0.1:8080/private',
690+
'/tmp/will-not-be-written',
691+
fetchImpl as typeof globalThis.fetch,
692+
{ sleep: noSleep },
693+
),
694+
).rejects.toMatchObject({
695+
code: 'VALIDATION_ERROR',
696+
details: expect.objectContaining({ field: 'artifact-url' }),
697+
});
698+
expect(calls).toBe(0);
699+
});
700+
701+
it('rejects oversized artifacts from Content-Length without retrying', async () => {
702+
let calls = 0;
703+
const fetchImpl = async () => {
704+
calls++;
705+
return new Response('too large', {
706+
status: 200,
707+
headers: { 'content-length': String(STREAM_URL_MAX_BYTES + 1) },
708+
});
709+
};
710+
await expect(
711+
streamUrlToFile(
712+
'https://example.com/huge',
713+
'/tmp/will-not-be-written',
714+
fetchImpl as typeof globalThis.fetch,
715+
{ sleep: noSleep },
716+
),
717+
).rejects.toMatchObject({
718+
code: 'PAYLOAD_TOO_LARGE',
719+
details: expect.objectContaining({ maxBytes: STREAM_URL_MAX_BYTES }),
720+
});
721+
expect(calls).toBe(1);
722+
});
723+
670724
it('sleeps between retries', async () => {
671725
const sleepDelays: number[] = [];
672726
const fetchImpl = async () => {
@@ -689,3 +743,46 @@ describe('streamUrlToFile retry', () => {
689743
expect(sleepDelays.every(d => d > 0)).toBe(true);
690744
});
691745
});
746+
747+
describe('writeBundle artifact filename safety', () => {
748+
it('sanitizes evidence kind before using it in a downloaded sidecar filename', async () => {
749+
const dir = mkdtempSync(join(tmpdir(), 'bundle-path-test-'));
750+
const ctx: CliFailureContext = {
751+
...baseCtx,
752+
result: { ...baseCtx.result, videoUrl: null },
753+
steps: [
754+
{
755+
...baseCtx.steps[2]!,
756+
screenshotUrl: null,
757+
htmlSnapshotUrl: null,
758+
},
759+
],
760+
failure: {
761+
...baseCtx.failure,
762+
evidence: [
763+
{
764+
kind: '../../escape' as unknown as 'log',
765+
stepIndex: 5,
766+
url: 'https://signed.example.com/escape.txt',
767+
summary: 'malicious kind',
768+
},
769+
],
770+
},
771+
};
772+
const fetchImpl = async () => new Response('sidecar', { status: 200 });
773+
774+
const result = await writeBundle(ctx, {
775+
dir,
776+
failedOnly: false,
777+
fetchImpl: fetchImpl as typeof globalThis.fetch,
778+
});
779+
780+
expect(result.files).toContain('steps/05-escape-0.txt');
781+
expect(existsSync(join(dir, 'steps', '05-escape-0.txt'))).toBe(true);
782+
expect(existsSync(join(dir, 'escape-0.txt'))).toBe(false);
783+
const evidence = JSON.parse(readFileSync(join(dir, 'steps', '05-evidence.json'), 'utf8')) as [
784+
{ path: string },
785+
];
786+
expect(evidence[0].path).toBe('steps/05-escape-0.txt');
787+
});
788+
});

src/lib/bundle.ts

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@
3434
*/
3535

3636
import { mkdir, mkdtemp, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
37-
import type { Writable } from 'node:stream';
37+
import { Readable, Transform, type TransformCallback, type Writable } from 'node:stream';
3838
import { pipeline } from 'node:stream/promises';
3939
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
4040
import { createWriteStream } from 'node:fs';
4141
import { dirname, isAbsolute, join, resolve } from 'node:path';
4242
import type { CliFailureContext, CliTestStep } from '../commands/test.js';
43-
import { ApiError, TransportError } from './errors.js';
43+
import { ApiError, localValidationError, TransportError } from './errors.js';
4444
import type { FetchImpl } from './http.js';
45+
import { assertNotLocal } from './target-url.js';
4546

4647
/** Schema version stamped into `meta.json`. Bumps with the contract. */
4748
export const BUNDLE_SCHEMA_VERSION = 'cli-v1' as const;
@@ -50,6 +51,10 @@ export const BUNDLE_SCHEMA_VERSION = 'cli-v1' as const;
5051
export const STREAM_URL_MAX_RETRIES = 3;
5152
/** Delay between retries for transient transport failures (ms). */
5253
export const STREAM_URL_RETRY_DELAY_MS = 1000;
54+
/** Hard deadline for a single artifact download attempt (ms). */
55+
export const STREAM_URL_TIMEOUT_MS = 30_000;
56+
/** Max bytes accepted from one presigned artifact URL. Keeps videos bounded. */
57+
export const STREAM_URL_MAX_BYTES = 200 * 1024 * 1024;
5358

5459
/** Default radius around the failed step when `--failed-only` is set. */
5560
export const FAILED_ONLY_RADIUS = 1;
@@ -350,6 +355,14 @@ export function pickCodeExtension(language: string, framework: string): string {
350355
* naturally without breaking existing fixtures.
351356
*/
352357
export function stepFilenamePrefix(stepIndex: number): string {
358+
if (!Number.isSafeInteger(stepIndex) || stepIndex < 0) {
359+
throw localValidationError(
360+
'steps[].stepIndex',
361+
'must be a non-negative integer',
362+
undefined,
363+
'field',
364+
);
365+
}
353366
return stepIndex >= 100 ? String(stepIndex).padStart(3, '0') : String(stepIndex).padStart(2, '0');
354367
}
355368

@@ -659,8 +672,9 @@ async function writeStepArtifacts(
659672
path: `steps/${prefix}-snapshot.html`,
660673
};
661674
}
675+
const kindSlug = safeFilenameSegment(entry.kind);
662676
const ext = sidecarExtension(entry.kind);
663-
const filename = `${prefix}-${entry.kind}-${i}.${ext}`;
677+
const filename = `${prefix}-${kindSlug}-${i}.${ext}`;
664678
await streamUrlToFile(entry.url, join(stepsTmpDir, filename), fetchImpl);
665679
filesWritten.push(`steps/${filename}`);
666680
return {
@@ -680,7 +694,7 @@ async function writeStepArtifacts(
680694
}
681695
}
682696

683-
function sidecarExtension(kind: 'log' | 'network' | 'console' | 'screenshot' | 'snapshot'): string {
697+
function sidecarExtension(kind: unknown): string {
684698
if (kind === 'network' || kind === 'console') return 'json';
685699
if (kind === 'screenshot') return 'png';
686700
if (kind === 'snapshot') return 'html';
@@ -690,6 +704,15 @@ function sidecarExtension(kind: 'log' | 'network' | 'console' | 'screenshot' | '
690704
return 'txt';
691705
}
692706

707+
function safeFilenameSegment(value: unknown): string {
708+
const raw = typeof value === 'string' ? value : '';
709+
const cleaned = raw
710+
.replace(/[^A-Za-z0-9_-]+/g, '-')
711+
.replace(/^-+|-+$/g, '')
712+
.slice(0, 64);
713+
return cleaned.length > 0 ? cleaned : 'artifact';
714+
}
715+
693716
/**
694717
* Stream a presigned URL into a file with bounded retry on transport
695718
* failures. 4xx (presigned URL expired or unauthorized) is NOT
@@ -711,12 +734,18 @@ export async function streamUrlToFile(
711734
fetchImpl: FetchImpl,
712735
deps?: { sleep?: (ms: number) => Promise<void> },
713736
): Promise<void> {
737+
validateArtifactUrl(url);
714738
const sleepFn = deps?.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
715739
for (let attempt = 1; attempt <= STREAM_URL_MAX_RETRIES; attempt++) {
740+
const controller = new AbortController();
741+
const timeout = setTimeout(() => controller.abort(), STREAM_URL_TIMEOUT_MS);
716742
let response: Response;
717743
try {
718-
response = await fetchImpl(url);
744+
response = await fetchImpl(url, { signal: controller.signal });
745+
assertContentLengthWithinLimit(response, url);
719746
} catch (err) {
747+
clearTimeout(timeout);
748+
if (err instanceof ApiError) throw err;
720749
const message = err instanceof Error ? err.message : String(err);
721750
if (attempt < STREAM_URL_MAX_RETRIES) {
722751
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
@@ -725,6 +754,7 @@ export async function streamUrlToFile(
725754
throw new TransportError(`Failed to download presigned URL ${url}: ${message}`);
726755
}
727756
if (!response.ok) {
757+
clearTimeout(timeout);
728758
// Non-2xx: the URL itself is bad (expired, unauthorized, not found).
729759
// Retrying the same URL won't help — surface immediately.
730760
throw ApiError.fromEnvelope({
@@ -746,9 +776,13 @@ export async function streamUrlToFile(
746776
// a tolerable amount of memory.
747777
try {
748778
const buffer = Buffer.from(await response.arrayBuffer());
779+
assertBytesWithinLimit(buffer.byteLength, url);
749780
await writeFile(filePath, buffer);
781+
clearTimeout(timeout);
750782
return;
751783
} catch (err) {
784+
clearTimeout(timeout);
785+
if (err instanceof ApiError) throw err;
752786
const message = err instanceof Error ? err.message : String(err);
753787
if (attempt < STREAM_URL_MAX_RETRIES) {
754788
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
@@ -766,11 +800,13 @@ export async function streamUrlToFile(
766800
const fileSink = createWriteStream(filePath);
767801
try {
768802
const webBody = response.body as unknown as NodeReadableStream<Uint8Array>;
769-
const { Readable } = await import('node:stream');
770803
const nodeStream = Readable.fromWeb(webBody);
771-
await pipeline(nodeStream, fileSink as unknown as Writable);
804+
await pipeline(nodeStream, artifactByteLimit(url), fileSink as unknown as Writable);
805+
clearTimeout(timeout);
772806
return;
773807
} catch (err) {
808+
clearTimeout(timeout);
809+
if (err instanceof ApiError) throw err;
774810
const message = err instanceof Error ? err.message : String(err);
775811
if (attempt < STREAM_URL_MAX_RETRIES) {
776812
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
@@ -781,6 +817,70 @@ export async function streamUrlToFile(
781817
}
782818
}
783819

820+
function validateArtifactUrl(url: string): void {
821+
try {
822+
assertNotLocal(url);
823+
} catch (err) {
824+
if (err instanceof ApiError) {
825+
throw ApiError.fromEnvelope({
826+
error: {
827+
code: 'VALIDATION_ERROR',
828+
message: 'Failure bundle artifact URL is invalid.',
829+
nextAction:
830+
'Re-run `testsprite test failure get`. Report the requestId to support if the bundle keeps returning a private or invalid artifact URL.',
831+
requestId: 'local',
832+
details: { ...err.details, field: 'artifact-url', url },
833+
},
834+
});
835+
}
836+
throw err;
837+
}
838+
}
839+
840+
function assertContentLengthWithinLimit(response: Response, url: string): void {
841+
const raw = response.headers.get('content-length');
842+
if (!raw) return;
843+
const parsed = Number(raw);
844+
if (!Number.isFinite(parsed) || parsed < 0) return;
845+
assertBytesWithinLimit(parsed, url);
846+
}
847+
848+
function artifactByteLimit(url: string): Transform {
849+
let bytes = 0;
850+
return new Transform({
851+
transform(
852+
chunk: Buffer | Uint8Array | string,
853+
encoding: BufferEncoding,
854+
cb: TransformCallback,
855+
) {
856+
const length =
857+
typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength;
858+
bytes += length;
859+
try {
860+
assertBytesWithinLimit(bytes, url);
861+
} catch (err) {
862+
cb(err as Error);
863+
return;
864+
}
865+
cb(null, chunk);
866+
},
867+
});
868+
}
869+
870+
function assertBytesWithinLimit(bytes: number, url: string): void {
871+
if (bytes <= STREAM_URL_MAX_BYTES) return;
872+
throw ApiError.fromEnvelope({
873+
error: {
874+
code: 'PAYLOAD_TOO_LARGE',
875+
message: 'Failure bundle artifact is too large.',
876+
nextAction:
877+
'Re-run `testsprite test failure get`. Report the requestId to support if the bundle keeps returning an oversized artifact.',
878+
requestId: 'local',
879+
details: { url, bytes, maxBytes: STREAM_URL_MAX_BYTES },
880+
},
881+
});
882+
}
883+
784884
function isPresignedUrl(value: string): boolean {
785885
return value.startsWith('https://');
786886
}

0 commit comments

Comments
 (0)