diff --git a/src/transforms/compression.js b/src/transforms/compression.js index 718e7fed..2352ac96 100644 --- a/src/transforms/compression.js +++ b/src/transforms/compression.js @@ -4,6 +4,11 @@ const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint const zlib = require('zlib') const Transform = require('readable-stream').Transform +// The protocol caps the uncompressed size of a packet at 2^23 bytes (8 MiB). A compressed +// packet that declares — or inflates to — more than this is rejected, so a malicious peer +// cannot force an unbounded synchronous allocation (a "decompression bomb"). See #664. +const MAX_UNCOMPRESSED_LENGTH = 8388608 // 2 ** 23 + module.exports.createCompressor = function (threshold) { return new Compressor(threshold) } @@ -53,9 +58,15 @@ class Decompressor extends Transform { if (value === 0) { this.push(chunk.slice(size)) return cb() + } else if (value > MAX_UNCOMPRESSED_LENGTH) { + // Declared uncompressed length exceeds the protocol maximum; refuse to inflate. + if (!this.hideErrors) { + console.error('uncompressed length ' + value + ' exceeds maximum of ' + MAX_UNCOMPRESSED_LENGTH) + } + return cb() } else { try { - const newBuf = zlib.unzipSync(chunk.slice(size), { finishFlush: 2 }) + const newBuf = zlib.unzipSync(chunk.slice(size), { maxOutputLength: MAX_UNCOMPRESSED_LENGTH, finishFlush: 2 }) if (newBuf.length !== value && !this.hideErrors) { console.error('uncompressed length should be ' + value + ' but is ' + newBuf.length) } diff --git a/test/compressionTest.js b/test/compressionTest.js new file mode 100644 index 00000000..b290f287 --- /dev/null +++ b/test/compressionTest.js @@ -0,0 +1,66 @@ +/* eslint-env mocha */ +// Tests the compression transforms, in particular that the decompressor enforces the +// protocol's maximum uncompressed packet size so a malicious peer cannot send a +// "decompression bomb" that inflates to an unbounded synchronous allocation. See #664. +const assert = require('assert') +const zlib = require('zlib') +const [, writeVarInt, sizeOfVarInt] = require('protodef').types.varint +const { supportedVersions } = require('../src/version') +const { createCompressor, createDecompressor } = require('../src/transforms/compression') + +const MAX_UNCOMPRESSED_LENGTH = 8388608 // 2 ** 23 + +// Push a single chunk through a transform and resolve with the array of emitted chunks. +function pump (transform, chunk) { + return new Promise((resolve, reject) => { + const out = [] + transform.on('data', (d) => out.push(d)) + transform.on('error', reject) + transform.write(chunk, (err) => { + if (err) return reject(err) + setImmediate(() => resolve(out)) + }) + }) +} + +// Build a compressed-packet frame: a VarInt declared uncompressed length followed by `payload`. +function framePacket (declaredLength, payload) { + const header = Buffer.alloc(sizeOfVarInt(declaredLength)) + writeVarInt(declaredLength, header, 0) + return Buffer.concat([header, payload]) +} + +// The transforms are version-independent, but the test job filters by `-g v`, +// so the suite is registered per supported version to run under CI. +for (const supportedVersion of supportedVersions) { + describe('compression ' + supportedVersion + 'v', () => { + it('round-trips a compressed packet', async () => { + const original = Buffer.from('a payload long enough to be worth compressing'.repeat(8)) + const [compressed] = await pump(createCompressor(0), original) + const [restored] = await pump(createDecompressor(0), compressed) + assert.ok(restored.equals(original), 'decompressed output should equal the original') + }) + + it('passes through an uncompressed packet (declared length 0)', async () => { + const original = Buffer.from('small') + // threshold above the payload size -> compressor emits it uncompressed (VarInt 0 prefix) + const [framed] = await pump(createCompressor(256), original) + const [restored] = await pump(createDecompressor(256), framed) + assert.ok(restored.equals(original)) + }) + + it('drops a decompression bomb instead of allocating past the maximum', async () => { + // Payload inflates to more than the protocol maximum, but declares a small length. + const bomb = zlib.deflateSync(Buffer.alloc(MAX_UNCOMPRESSED_LENGTH + 1024)) + const packet = framePacket(100, bomb) + const out = await pump(createDecompressor(0, true), packet) + assert.strictEqual(out.length, 0, 'oversized inflate should be dropped, not emitted') + }) + + it('rejects a packet whose declared uncompressed length exceeds the maximum', async () => { + const packet = framePacket(MAX_UNCOMPRESSED_LENGTH + 1, zlib.deflateSync(Buffer.from('x'))) + const out = await pump(createDecompressor(0, true), packet) + assert.strictEqual(out.length, 0, 'over-cap declared length should be dropped before inflating') + }) + }) +}