Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/transforms/compression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
66 changes: 66 additions & 0 deletions test/compressionTest.js
Original file line number Diff line number Diff line change
@@ -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 <version>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')
})
})
}
Loading