77 lines
3.4 KiB
JavaScript
77 lines
3.4 KiB
JavaScript
// A scanned QR code is fully attacker-controlled input, and DEFLATE compresses
|
|||
|
|
// repetitive data ~1000:1 — so a QR small enough to print on a sticker can
|
||
|
|
// expand to hundreds of megabytes and OOM-kill the tab, taking the live session
|
||
|
|
// and its keys with it.
|
||
|
|
//
|
||
|
|
// The subtlety this file exists for: pako documents a `maxOutputLength` option,
|
||
|
|
// and pako 2.1.0 SILENTLY IGNORES IT. Passing it looks like a fix, passes review
|
||
|
|
// and does nothing. These assertions pin the behaviour we actually depend on.
|
||
|
|
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import pako from 'pako';
|
||
|
|
import { readFileSync } from 'node:fs';
|
||
|
|
|
||
|
|
const source = readFileSync(new URL('../src/crypto/cose-qr.js', import.meta.url), 'utf8');
|
||
|
|
|
||
|
|
// ── the option we cannot rely on ─────────────────────────────────────────────
|
||
|
|
{
|
||
|
|
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024)); // 8 MB of zeros
|
||
|
|
assert.ok(bomb.length < 64 * 1024, 'sanity: the bomb really is small compressed');
|
||
|
|
|
||
|
|
const ignored = pako.inflate(bomb, { maxOutputLength: 256 * 1024 });
|
||
|
|
// If this ever starts throwing (or truncating), pako has gained real support
|
||
|
|
// and inflateBounded could be simplified — but only then, deliberately.
|
||
|
|
assert.equal(ignored.length, 8 * 1024 * 1024,
|
||
|
|
'pako still ignores maxOutputLength; the streaming guard is load-bearing');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── the QR path must not use the one-shot helper ─────────────────────────────
|
||
|
|
assert.equal(
|
||
|
|
/pako\.inflate\(/.test(source), false,
|
||
|
|
'the one-shot pako.inflate does not bound output and must not be used on QR input'
|
||
|
|
);
|
||
|
|
assert.ok(/new pako\.Inflate\(/.test(source), 'the streaming API is what enforces the bound');
|
||
|
|
|
||
|
|
// ── the real helper, exercised as shipped ────────────────────────────────────
|
||
|
|
const helperSource = source.slice(
|
||
|
|
source.indexOf('const MAX_INFLATED_QR_BYTES'),
|
||
|
|
source.indexOf('// Generate UUID for chunking')
|
||
|
|
);
|
||
|
|
const inflateBounded = new Function('pako', `${helperSource}; return inflateBounded;`)(pako);
|
||
|
|
|
||
|
|
{
|
||
|
|
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024));
|
||
|
|
assert.throws(
|
||
|
|
() => inflateBounded(bomb, 'test'),
|
||
|
|
/expands beyond/,
|
||
|
|
'a zip bomb must be refused'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// A normal offer still round-trips — a bound that breaks decompression would be
|
||
|
|
// removed by the next person who hits it.
|
||
|
|
{
|
||
|
|
const payload = JSON.stringify({ type: 'enhanced_secure_offer', sdp: 'v=0\r\n'.repeat(200) });
|
||
|
|
const restored = inflateBounded(pako.deflate(new TextEncoder().encode(payload)), 'test');
|
||
|
|
assert.equal(new TextDecoder().decode(restored), payload, 'a real offer must decompress intact');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Right below the ceiling is fine; just above it is not.
|
||
|
|
{
|
||
|
|
const under = new Uint8Array(200 * 1024).map((_, i) => i % 251);
|
||
|
|
assert.equal(inflateBounded(pako.deflate(under), 'test').length, under.length);
|
||
|
|
|
||
|
|
const over = new Uint8Array(300 * 1024).map((_, i) => i % 251);
|
||
|
|
assert.throws(() => inflateBounded(pako.deflate(over), 'test'), /expands beyond/);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Garbage input fails cleanly rather than hanging or returning junk.
|
||
|
|
{
|
||
|
|
assert.throws(
|
||
|
|
() => inflateBounded(new Uint8Array([1, 2, 3, 4, 5]), 'test'),
|
||
|
|
/could not be decompressed/
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('qr-zip-bomb.test.mjs: all assertions passed');
|