1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | /**
* Size-bounded gzip decompression.
*
* `Bun.gunzipSync` has two properties that make it unsafe on a request path
* carrying attacker-controlled bytes:
*
* 1. No output bound. gzip reaches compression ratios past 1000:1 on
* repetitive input, so a 10 MB upload expands to ~10 GB in memory. The
* process is killed long before that completes.
* 2. It is synchronous. Decompression blocks the event loop for its whole
* duration, so a single large body stalls every other request the
* server is handling — a latency DoS independent of the memory one.
*
* This helper streams through `DecompressionStream`, checks a running total
* after every chunk, and aborts the moment the budget is exceeded. Memory is
* bounded by `limit` rather than by the compression ratio, and awaiting each
* chunk yields to the event loop instead of monopolising it.
*/
/** Thrown when decompressed output exceeds the caller's budget. */
export class DecompressedTooLargeError extends Error {
readonly limit: number;
constructor(limit: number) {
super(`decompressed body exceeds ${limit} bytes`);
this.name = "DecompressedTooLargeError";
this.limit = limit;
}
}
/**
* Default ceiling for a git push body after decompression.
*
* Generous on purpose: git objects inside a packfile are already zlib
* compressed, so a genuine push gzips at close to 1:1 and a real one this
* large would be extraordinary. A bomb, by contrast, blows through this
* within the first few chunks and is cut off there.
*/
export const MAX_GIT_BODY_BYTES = 1024 * 1024 * 1024; // 1 GiB
/** True if `bytes` starts with the gzip magic number. */
export function isGzip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
}
/**
* Decompress `input`, refusing to buffer more than `limit` bytes of output.
*
* @throws {DecompressedTooLargeError} as soon as the budget is exceeded —
* before the offending data is retained, not after.
*/
export async function gunzipBounded(
input: Uint8Array,
limit: number = MAX_GIT_BODY_BYTES
): Promise<Uint8Array> {
const stream = new Blob([input as BlobPart])
.stream()
.pipeThrough(new DecompressionStream("gzip"));
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
// Check BEFORE retaining the chunk, so exceeding the budget never
// costs more than one chunk of headroom.
if (total > limit) {
throw new DecompressedTooLargeError(limit);
}
chunks.push(value);
}
} finally {
// Releases the underlying source whether we finished or bailed out.
reader.cancel().catch(() => {});
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
|