Skip to content

Commit 2316998

Browse files
committed
v2.4.1: gzipDecode is now decompression-bomb safe
Symmetry fix. brotli-decode.ts has had a 1 MB MAX_DECOMPRESSED_SIZE cap with a real on-chain-style fixture (testdata/brotli-decompression-bomb.txt.br -> 1 GB unzipped) since forever. The gzip path in inscription-parser.service.helper.ts had a bare 'while (true) chunks.push(value)' loop and would happily allocate gigabytes if a future inscription used gzip Content-Encoding to ship a HAHAHA-style bomb. Now: - gzipDecode tracks running totalSize, throws MAX_DECOMPRESSED_SIZE_MESSAGE the moment the streamed output crosses the cap, cancels the reader so the underlying DecompressionStream stops pulling, and returns the sentinel bytes the same way brotliDecodeUint8Array does. - writer.write() / writer.close() now .catch() the abort error that follows reader.cancel() (was unhandled before). - testdata/gzip-decompression-bomb.sh mirrors brotli-decompression-bomb.sh line for line (s/brotli/gzip/), with a one-line note about gzip's auto-removal of the source file. - testdata/gzip-decompression-bomb.txt.gz committed (949 KB compressed, decompresses to ~1 GB). - inscription-parser.service.gzip.spec.ts no longer has the /* it('should survive a decompression bomb') ... TODO! */ placeholder; it now has a real assertion that returns MAX_DECOMPRESSED_SIZE_MESSAGE, matching brotli's spec exactly. 861 tests pass in both jest configs (was 860). Note: the on-chain inscription that motivated this is one of the HAHAHA-bomb cats (in the first ~300 cat21 mints). The exact txid is unknown -- the synthetic fixture is sufficient for regression. \xf0\x9f\x98\xba
1 parent 016be86 commit 2316998

5 files changed

Lines changed: 64 additions & 16 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ordpool-parser",
3-
"version": "2.4.0",
3+
"version": "2.4.1",
44
"description": "Zero-dependency TypeScript parser for Bitcoin digital artifacts: Inscriptions, Runes, BRC-20, SRC-20, CAT-21, Atomicals, Labitbu, OpenTimestamps. Works in Node.js and browsers.",
55
"repository": {
66
"type": "git",

src/inscription/inscription-parser.service.gzip.spec.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { readInscriptionAsBase64, readTransaction } from '../../testdata/test.helper';
1+
import { MAX_DECOMPRESSED_SIZE_MESSAGE } from '../lib/brotli-decode';
2+
import { bytesToBinaryString } from '../lib/conversions';
3+
import { readBinaryFileAsUint8Array, readInscriptionAsBase64, readTransaction } from '../../testdata/test.helper';
24
import { InscriptionParserService } from './inscription-parser.service';
5+
import { gzipDecode } from './inscription-parser.service.helper';
36

47
describe('Inscription parser', () => {
58

@@ -16,9 +19,10 @@ describe('Inscription parser', () => {
1619
expect(actualFileData).toEqual(expectedFileData);
1720
});
1821

19-
/*
20-
it('should survive a decompression bomb', () => {
21-
// TODO! add mitigations!
22+
it('should survive a decompression bomb', async () => {
23+
const bomb = readBinaryFileAsUint8Array('gzip-decompression-bomb.txt.gz');
24+
const contentRaw = await gzipDecode(bomb);
25+
const content = bytesToBinaryString(contentRaw);
26+
expect(content).toEqual(MAX_DECOMPRESSED_SIZE_MESSAGE);
2227
});
23-
*/
2428
});

src/inscription/inscription-parser.service.helper.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { INVALID_COMPRESSED_DATA_MESSAGE, MAX_DECOMPRESSED_SIZE_MESSAGE, brotliDecode } from "../lib/brotli-decode";
1+
import { INVALID_COMPRESSED_DATA_MESSAGE, MAX_DECOMPRESSED_SIZE, MAX_DECOMPRESSED_SIZE_MESSAGE, brotliDecode } from "../lib/brotli-decode";
22
import { hexToBytes, isStringInArrayOfStrings, littleEndianBytesToNumber } from "../lib/conversions";
33
import { bytesToHex } from "../lib/conversions";
44
import { OP_ENDIF, OP_FALSE, OP_IF, OP_PUSHBYTES_3 } from "../lib/op-codes";
@@ -217,10 +217,18 @@ export function brotliDecodeUint8Array(bytes: Uint8Array): Uint8Array {
217217
}
218218

219219
/**
220-
* Decompresses gzip-encoded bytes via DecompressionStream. Returns
221-
* INVALID_COMPRESSED_DATA_MESSAGE (UTF-8) on decode failure -- mirrors
222-
* brotliDecodeUint8Array. Throws only when DecompressionStream itself
223-
* is unavailable (host environment, not a data problem).
220+
* Decompresses gzip-encoded bytes via DecompressionStream. Returns:
221+
* - the decompressed bytes on success
222+
* - MAX_DECOMPRESSED_SIZE_MESSAGE (UTF-8) if the result would exceed the cap
223+
* - INVALID_COMPRESSED_DATA_MESSAGE (UTF-8) on any other decode failure
224+
*
225+
* Mirrors brotliDecodeUint8Array's behaviour. Decompression-bomb safety: a
226+
* malicious gzip payload that expands to gigabytes is aborted as soon as the
227+
* streamed output crosses MAX_DECOMPRESSED_SIZE -- we never allocate the full
228+
* pathological output.
229+
*
230+
* Throws only when DecompressionStream itself is unavailable (host
231+
* environment, not a data problem).
224232
*/
225233
export async function gzipDecode(bytes: Uint8Array): Promise<Uint8Array> {
226234
if (typeof DecompressionStream === 'undefined') {
@@ -232,12 +240,15 @@ export async function gzipDecode(bytes: Uint8Array): Promise<Uint8Array> {
232240
try {
233241
const ds = new DecompressionStream('gzip');
234242

235-
// Write the input bytes to the stream
243+
// Write the input bytes to the stream. We swallow the per-call rejections
244+
// so that cancelling the reader on a bomb doesn't surface as an
245+
// unhandled "ABORT_ERR" from the still-in-flight writer.
236246
const writer = ds.writable.getWriter();
237-
writer.write(bytes);
238-
writer.close();
247+
writer.write(bytes).catch(() => { /* aborted on bomb cancel */ });
248+
writer.close().catch(() => { /* aborted on bomb cancel */ });
239249

240-
// Read and concatenate the output bytes
250+
// Read and concatenate the output bytes, aborting if the running total
251+
// would exceed MAX_DECOMPRESSED_SIZE (decompression-bomb mitigation).
241252
const reader = ds.readable.getReader();
242253
const chunks: Uint8Array[] = [];
243254
let totalSize = 0;
@@ -249,6 +260,12 @@ export async function gzipDecode(bytes: Uint8Array): Promise<Uint8Array> {
249260
if (value) {
250261
chunks.push(value);
251262
totalSize += value.byteLength;
263+
if (totalSize > MAX_DECOMPRESSED_SIZE) {
264+
// Cancel the stream so the underlying DecompressionStream stops
265+
// pulling more input -- we don't want to keep decompressing a bomb.
266+
await reader.cancel();
267+
throw new Error(MAX_DECOMPRESSED_SIZE_MESSAGE);
268+
}
252269
}
253270
}
254271

@@ -261,7 +278,10 @@ export async function gzipDecode(bytes: Uint8Array): Promise<Uint8Array> {
261278
}
262279

263280
return result;
264-
} catch {
281+
} catch (error) {
282+
if (error instanceof Error && error.message === MAX_DECOMPRESSED_SIZE_MESSAGE) {
283+
return new TextEncoder().encode(MAX_DECOMPRESSED_SIZE_MESSAGE);
284+
}
265285
return new TextEncoder().encode(INVALID_COMPRESSED_DATA_MESSAGE);
266286
}
267287
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/bash
2+
3+
# Script to create a gzip decompression bomb for testing
4+
# Warning: The uncompressed file is 1 GB large!
5+
6+
# Configurable parameters
7+
OUTPUT_FILE="gzip-decompression-bomb.txt"
8+
COMPRESSED_FILE="${OUTPUT_FILE}.gz"
9+
REPEAT_STRING="HAHAHAHAHA"
10+
REPEAT_COUNT=100000000 # Number of times the string is repeated
11+
12+
# Delete the OUTPUT_FILE and COMPRESSED_FILE if they exist
13+
[ -f "$OUTPUT_FILE" ] && rm "$OUTPUT_FILE"
14+
[ -f "$COMPRESSED_FILE" ] && rm "$COMPRESSED_FILE"
15+
16+
# Create a single long line of repetitive data
17+
printf "%0.s$REPEAT_STRING" $(seq 1 $REPEAT_COUNT) > "$OUTPUT_FILE"
18+
19+
# Compress the file using gzip
20+
# (gzip removes the uncompressed source by default; brotli doesn't, hence the
21+
# explicit `rm` in the brotli equivalent.)
22+
gzip --best "$OUTPUT_FILE"
23+
24+
echo "Created and compressed bomb file: $COMPRESSED_FILE"
949 KB
Binary file not shown.

0 commit comments

Comments
 (0)