Skip to content

Commit 19fb158

Browse files
Refs #206: bound portable archive extraction
Enforce fixed compressed/inflated/member/payload limits and safe flat TAR extraction before any member write.
1 parent 4cd118d commit 19fb158

3 files changed

Lines changed: 693 additions & 34 deletions

File tree

backend/scripts/restore-drill.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,6 @@ async function main(): Promise<void> {
4848
}
4949

5050
main().catch((err) => {
51-
console.error(err);
51+
console.error((err as Error)?.message || 'Restore drill failed');
5252
process.exit(1);
5353
});

backend/src/export/archive.ts

Lines changed: 251 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import crypto from 'crypto';
22
import fs from 'fs/promises';
33
import os from 'os';
44
import path from 'path';
5+
import { Readable } from 'stream';
56
import { promisify } from 'util';
67
import zlib from 'zlib';
78

@@ -43,6 +44,7 @@ interface RestoreEvidenceOptions {
4344
timestamp?: string;
4445
smokeChecksPassed?: boolean;
4546
s3Client?: Pick<S3Client, 'send'>;
47+
extractionLimits?: Partial<ArchiveExtractionLimits>;
4648
}
4749

4850
interface RestoreEvidenceReport {
@@ -71,6 +73,32 @@ interface RestoreEvidenceResult {
7173
}
7274

7375
const TAR_BLOCK_SIZE = 512;
76+
const MIB = 1024 * 1024;
77+
const SAFE_ARCHIVE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
78+
79+
export interface ArchiveExtractionLimits {
80+
maxCompressedArchiveBytes: number;
81+
maxInflatedArchiveBytes: number;
82+
maxMembers: number;
83+
maxMemberBytes: number;
84+
maxAggregatePayloadBytes: number;
85+
}
86+
87+
export const DEFAULT_ARCHIVE_EXTRACTION_LIMITS: Readonly<ArchiveExtractionLimits> = Object.freeze({
88+
maxCompressedArchiveBytes: 128 * MIB,
89+
maxInflatedArchiveBytes: 256 * MIB,
90+
maxMembers: 128,
91+
maxMemberBytes: 64 * MIB,
92+
maxAggregatePayloadBytes: 192 * MIB,
93+
});
94+
95+
class ArchiveBoundaryError extends Error {
96+
constructor(message: string) {
97+
super(message);
98+
this.name = 'ArchiveBoundaryError';
99+
}
100+
}
101+
74102
const gzip = promisify(zlib.gzip);
75103
const gunzip = promisify(zlib.gunzip);
76104
const RESTORE_SMOKE_CHECKS = [
@@ -126,6 +154,52 @@ function parseS3Uri(uri: string): { bucket: string; key: string } | null {
126154
return { bucket: match[1], key: match[2] };
127155
}
128156

157+
function resolveArchiveExtractionLimits(
158+
overrides?: Partial<ArchiveExtractionLimits>,
159+
): ArchiveExtractionLimits {
160+
const resolved = { ...DEFAULT_ARCHIVE_EXTRACTION_LIMITS };
161+
162+
for (const [key, value] of Object.entries(overrides || {}) as Array<[keyof ArchiveExtractionLimits, unknown]>) {
163+
if (value === undefined) continue;
164+
if (
165+
!Number.isSafeInteger(value)
166+
|| Number(value) <= 0
167+
|| Number(value) > DEFAULT_ARCHIVE_EXTRACTION_LIMITS[key]
168+
) {
169+
throw new ArchiveBoundaryError(
170+
`Archive extraction limit ${key} must be at or below the named default`,
171+
);
172+
}
173+
resolved[key] = Number(value);
174+
}
175+
176+
return resolved;
177+
}
178+
179+
async function readBoundedChunks(
180+
chunks: AsyncIterable<Uint8Array>,
181+
maxBytes: number,
182+
): Promise<Buffer> {
183+
const collected: Buffer[] = [];
184+
let size = 0;
185+
186+
try {
187+
for await (const chunk of chunks) {
188+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
189+
size += bytes.length;
190+
if (size > maxBytes) {
191+
throw new ArchiveBoundaryError('Archive exceeds the compressed-size limit');
192+
}
193+
collected.push(bytes);
194+
}
195+
} catch (error) {
196+
if (error instanceof ArchiveBoundaryError) throw error;
197+
throw new ArchiveBoundaryError('Archive could not be read');
198+
}
199+
200+
return Buffer.concat(collected, size);
201+
}
202+
129203
function archiveKeyFromUri(uri: string): string {
130204
const s3 = parseS3Uri(uri);
131205
if (s3) return s3.key;
@@ -171,66 +245,213 @@ async function createExportArchive(exportDir: string, manifest: Manifest): Promi
171245
return gzip(Buffer.concat(blocks));
172246
}
173247

174-
async function extractExportArchive(archiveBuffer: Buffer, outputDir: string): Promise<void> {
175-
await fs.mkdir(outputDir, { recursive: true });
176-
const tar = await gunzip(archiveBuffer);
248+
async function extractExportArchive(
249+
archiveBuffer: Buffer,
250+
outputDir: string,
251+
limitOverrides?: Partial<ArchiveExtractionLimits>,
252+
): Promise<void> {
253+
const limits = resolveArchiveExtractionLimits(limitOverrides);
254+
if (archiveBuffer.length > limits.maxCompressedArchiveBytes) {
255+
throw new ArchiveBoundaryError('Archive exceeds the compressed-size limit');
256+
}
257+
258+
let tar: Buffer;
259+
try {
260+
tar = await gunzip(archiveBuffer, { maxOutputLength: limits.maxInflatedArchiveBytes });
261+
} catch (error) {
262+
if ((error as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') {
263+
throw new ArchiveBoundaryError('Archive exceeds the inflated-size limit');
264+
}
265+
throw new ArchiveBoundaryError('Archive stream is truncated or malformed');
266+
}
267+
if (tar.length > limits.maxInflatedArchiveBytes) {
268+
throw new ArchiveBoundaryError('Archive exceeds the inflated-size limit');
269+
}
270+
177271
let offset = 0;
272+
let memberOrdinal = 0;
273+
let aggregateSize = 0;
274+
const members: Array<{ name: string; content: Buffer }> = [];
275+
const seenNames = new Set<string>();
178276

179277
while (offset + TAR_BLOCK_SIZE <= tar.length) {
180278
const header = tar.subarray(offset, offset + TAR_BLOCK_SIZE);
181-
offset += TAR_BLOCK_SIZE;
182-
if (header.every((byte) => byte === 0)) break;
279+
280+
if (header.every((byte) => byte === 0)) {
281+
const trailing = tar.subarray(offset + TAR_BLOCK_SIZE);
282+
if (
283+
trailing.length < TAR_BLOCK_SIZE
284+
|| !trailing.subarray(0, TAR_BLOCK_SIZE).every((byte) => byte === 0)
285+
) {
286+
throw new ArchiveBoundaryError('Archive is truncated');
287+
}
288+
if (trailing.subarray(TAR_BLOCK_SIZE).some((byte) => byte !== 0)) {
289+
throw new ArchiveBoundaryError('Archive contains nonzero trailing data');
290+
}
291+
292+
await fs.mkdir(outputDir, { recursive: true });
293+
for (const member of members) {
294+
await fs.writeFile(path.join(outputDir, member.name), member.content);
295+
}
296+
return;
297+
}
298+
299+
memberOrdinal += 1;
300+
if (memberOrdinal > limits.maxMembers) {
301+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} exceeds the member-count limit`);
302+
}
303+
183304
validateTarHeaderChecksum(header);
305+
offset += TAR_BLOCK_SIZE;
184306

185-
const rawName = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
186-
const filename = path.basename(rawName);
187-
if (!filename || filename !== rawName) {
188-
throw new Error(`Archive contains unsafe path: ${rawName}`);
307+
const nameField = header.subarray(0, 100);
308+
const nameEnd = nameField.indexOf(0);
309+
const rawName = nameEnd >= 0
310+
? nameField.subarray(0, nameEnd).toString('utf8')
311+
: '';
312+
if (
313+
nameEnd < 0
314+
|| nameField.subarray(nameEnd + 1).some((byte) => byte !== 0)
315+
|| !SAFE_ARCHIVE_FILENAME.test(rawName)
316+
) {
317+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} has an unsafe flat name`);
318+
}
319+
if (header.subarray(345, 500).some((byte) => byte !== 0)) {
320+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} has an unsafe flat name`);
189321
}
190322

191323
const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim();
192-
const size = Number.parseInt(sizeText || '0', 8);
193-
if (!Number.isFinite(size) || size < 0) {
194-
throw new Error(`Archive contains invalid size for ${filename}`);
324+
const size = /^[0-7]*$/.test(sizeText) ? Number.parseInt(sizeText || '0', 8) : Number.NaN;
325+
if (!Number.isSafeInteger(size) || size < 0) {
326+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} has an invalid size`);
327+
}
328+
if (size > limits.maxMemberBytes) {
329+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} exceeds the member-size limit`);
330+
}
331+
aggregateSize += size;
332+
if (aggregateSize > limits.maxAggregatePayloadBytes) {
333+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} exceeds the aggregate-payload limit`);
195334
}
196335
if (offset + size > tar.length) {
197-
throw new Error(`Archive member ${filename} is truncated`);
336+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} is truncated`);
198337
}
199338

200-
const content = tar.subarray(offset, offset + size);
201-
await fs.writeFile(path.join(outputDir, filename), content);
339+
const typeFlag = header.subarray(156, 157).toString('ascii');
340+
if (typeFlag !== '0') {
341+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} has an unsupported type`);
342+
}
343+
const collisionKey = rawName.toLocaleLowerCase('en-US');
344+
if (seenNames.has(collisionKey)) {
345+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} duplicates a flat filename`);
346+
}
347+
seenNames.add(collisionKey);
348+
349+
members.push({ name: rawName, content: tar.subarray(offset, offset + size) });
202350
offset += size;
203351
const padding = (TAR_BLOCK_SIZE - (size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
352+
if (offset + padding > tar.length) {
353+
throw new ArchiveBoundaryError(`Archive member ${memberOrdinal} is truncated`);
354+
}
204355
offset += padding;
205356
}
357+
358+
throw new ArchiveBoundaryError('Archive is truncated');
206359
}
207360

208361
function validateTarHeaderChecksum(header: Buffer): void {
209362
const storedText = header.subarray(148, 156).toString('ascii').replace(/\0.*$/, '').trim();
210-
const stored = Number.parseInt(storedText || '', 8);
211-
if (!Number.isFinite(stored)) throw new Error('Archive contains an invalid tar header checksum');
363+
const stored = /^[0-7]+$/.test(storedText) ? Number.parseInt(storedText, 8) : Number.NaN;
364+
if (!Number.isFinite(stored)) throw new ArchiveBoundaryError('Archive contains an invalid tar header checksum');
212365
const copy = Buffer.from(header);
213366
copy.fill(' ', 148, 156);
214367
let calculated = 0;
215368
for (const byte of copy) calculated += byte;
216-
if (stored !== calculated) throw new Error('Archive tar header checksum mismatch');
369+
if (stored !== calculated) throw new ArchiveBoundaryError('Archive contains an invalid tar header checksum');
217370
}
218371

219-
async function readArchiveUri(uri: string, s3Client: Pick<S3Client, 'send'> = new S3Client({})): Promise<Buffer> {
372+
async function readLocalArchive(filePath: string, maxBytes: number): Promise<Buffer> {
373+
let handle;
374+
try {
375+
handle = await fs.open(filePath, 'r');
376+
const stats = await handle.stat();
377+
if (stats.size > maxBytes) {
378+
throw new ArchiveBoundaryError('Archive exceeds the compressed-size limit');
379+
}
380+
381+
return await readBoundedChunks(handle.createReadStream(), maxBytes);
382+
} catch (error) {
383+
if (error instanceof ArchiveBoundaryError) throw error;
384+
throw new ArchiveBoundaryError('Archive could not be read');
385+
} finally {
386+
await handle?.close().catch(() => undefined);
387+
}
388+
}
389+
390+
async function readS3Archive(
391+
s3Client: Pick<S3Client, 'send'>,
392+
bucket: string,
393+
key: string,
394+
maxBytes: number,
395+
): Promise<Buffer> {
396+
let response;
397+
try {
398+
response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
399+
} catch {
400+
throw new ArchiveBoundaryError('Archive could not be read');
401+
}
402+
403+
if (response.ContentLength !== undefined) {
404+
if (
405+
!Number.isSafeInteger(response.ContentLength)
406+
|| response.ContentLength < 0
407+
|| response.ContentLength > maxBytes
408+
) {
409+
throw new ArchiveBoundaryError('Archive exceeds the compressed-size limit');
410+
}
411+
}
412+
413+
const body = response.Body;
414+
try {
415+
if (body instanceof Readable) {
416+
return await readBoundedChunks(body, maxBytes);
417+
}
418+
419+
if (typeof body === 'object' && body !== null && Symbol.asyncIterator in body) {
420+
return await readBoundedChunks(body as AsyncIterable<Uint8Array>, maxBytes);
421+
}
422+
423+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
424+
if (body.size > maxBytes) {
425+
throw new ArchiveBoundaryError('Archive exceeds the compressed-size limit');
426+
}
427+
return await readBoundedChunks(body.stream() as unknown as AsyncIterable<Uint8Array>, maxBytes);
428+
}
429+
430+
const streamBody = body as { stream?: () => unknown };
431+
if (typeof streamBody?.stream === 'function') {
432+
return await readBoundedChunks(streamBody.stream() as AsyncIterable<Uint8Array>, maxBytes);
433+
}
434+
435+
throw new ArchiveBoundaryError('Archive body is not bounded-readable');
436+
} catch (error) {
437+
if (error instanceof ArchiveBoundaryError) throw error;
438+
throw new ArchiveBoundaryError('Archive body could not be read');
439+
}
440+
}
441+
442+
async function readArchiveUri(
443+
uri: string,
444+
s3Client: Pick<S3Client, 'send'> = new S3Client({}),
445+
limitOverrides?: Partial<ArchiveExtractionLimits>,
446+
): Promise<Buffer> {
447+
const limits = resolveArchiveExtractionLimits(limitOverrides);
220448
const s3 = parseS3Uri(uri);
221449
if (!s3) {
222450
const filePath = uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
223-
return fs.readFile(filePath);
451+
return readLocalArchive(filePath, limits.maxCompressedArchiveBytes);
224452
}
225453

226-
const response = await s3Client.send(new GetObjectCommand({ Bucket: s3.bucket, Key: s3.key }));
227-
const body = response.Body as unknown as {
228-
transformToByteArray?: () => Promise<Uint8Array>;
229-
};
230-
if (!body || typeof body.transformToByteArray !== 'function') {
231-
throw new Error('S3 archive response body is not readable');
232-
}
233-
return Buffer.from(await body.transformToByteArray());
454+
return readS3Archive(s3Client, s3.bucket, s3.key, limits.maxCompressedArchiveBytes);
234455
}
235456

236457
async function writeLocalArchive(localArchiveDir: string, archiveKey: string, content: Buffer): Promise<string> {
@@ -306,15 +527,15 @@ async function writeRestoreEvidence(options: RestoreEvidenceOptions): Promise<Re
306527
if (!/^sha256:[a-f0-9]{64}$/.test(options.expectedArchiveChecksum)) {
307528
throw new Error('Restore evidence requires an expected sha256 archive checksum');
308529
}
309-
const archiveBuffer = await readArchiveUri(options.archiveUri, options.s3Client);
530+
const archiveBuffer = await readArchiveUri(options.archiveUri, options.s3Client, options.extractionLimits);
310531
const calculatedArchiveChecksum = sha256Bytes(archiveBuffer);
311532
if (calculatedArchiveChecksum !== options.expectedArchiveChecksum) {
312533
throw new Error('Archive checksum mismatch');
313534
}
314535
await fs.mkdir(options.outputDir, { recursive: true });
315536
const timestamp = options.timestamp || new Date().toISOString();
316537
const extractedDir = path.join(options.outputDir, `extracted-${timestamp.replace(/[:.]/g, '-')}`);
317-
await extractExportArchive(archiveBuffer, extractedDir);
538+
await extractExportArchive(archiveBuffer, extractedDir, options.extractionLimits);
318539

319540
const manifest = JSON.parse(await fs.readFile(path.join(extractedDir, 'manifest.json'), 'utf8')) as Manifest;
320541
const validation = await validatePortableExport(extractedDir);

0 commit comments

Comments
 (0)