Skip to content

Commit 7e3950c

Browse files
committed
fix(node_binding): harden the compressed-binding packer per review
- unpack() fails closed instead of crashing: bound compressed/uncompressed lengths before narrowing to Number (no precision loss), reject a compressed length that overruns the section, and catch zstd decompress errors. - drop the unused fs import from the packer. - bench: resolve the script dir with fileURLToPath (correct on Windows) instead of a URL pathname. - fix header comment grammar (unwraps / creator-side). - add unit tests for the overrun and non-zstd-payload paths.
1 parent b13e785 commit 7e3950c

3 files changed

Lines changed: 59 additions & 9 deletions

File tree

crates/node_binding/scripts/bench-compressed-binding.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createRequire } from 'node:module'
1616
import fs from 'node:fs'
1717
import path from 'node:path'
1818
import process from 'node:process'
19+
import { fileURLToPath } from 'node:url'
1920
import zlib from 'node:zlib'
2021

2122
const require = createRequire(import.meta.url)
@@ -44,7 +45,7 @@ function resolveBindingNode(triple) {
4445
try {
4546
return require.resolve(`@rspack/binding-${triple}/${file}`)
4647
} catch {}
47-
const roots = [process.cwd(), path.dirname(new URL(import.meta.url).pathname)]
48+
const roots = [process.cwd(), path.dirname(fileURLToPath(import.meta.url))]
4849
for (const root of roots) {
4950
let dir = root
5051
for (let i = 0; i < 8; i += 1) {

crates/node_binding/scripts/pack-compressed-binding.mjs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
// Pack a built `rspack.<platform>.node` into the "pressed-data" payload used by
22
// the compressed-binding loader: zstd-compressed addon + SHA-512 integrity, in
3-
// the same layout the `decmpfs` crate (and bin-infra) already unwrap. This is
4-
// the create-side reference; the build step injects the payload into the
3+
// the same layout the `decmpfs` crate (and bin-infra) already unwraps. This is
4+
// the creator-side reference; the build step injects the payload into the
55
// binary's PRESSED_DATA section and the Rust loader unwraps it on first load.
66
//
77
// Format: [magic 32B "__SMOL_PRESSED_DATA_MAGIC_MARKER"]
88
// [compressed u64 LE][uncompressed u64 LE][cache key 16B][platform 3B]
99
// [SHA-512 of the zstd payload 64B][has_config 1B][zstd frame]
1010
//
1111
// No @napi-rs/cli, no legacy-Node fallback — modern node:zlib/node:crypto only.
12-
import zlib from 'node:zlib'
1312
import crypto from 'node:crypto'
14-
import fs from 'node:fs'
13+
import zlib from 'node:zlib'
1514

1615
const MAGIC = Buffer.from('__SMOL_PRESSED_DATA_MAGIC_MARKER') // 32B
1716
const CACHE_KEY_LEN = 16
@@ -43,15 +42,17 @@ export function pack(raw, options) {
4342
return Buffer.concat([head, payload])
4443
}
4544

45+
const MAX_LEN = 512 * 1024 * 1024 // reject implausible sizes; keeps sizes in Number's safe range
46+
4647
export function unpack(section) {
4748
const min = MAGIC.length + 8 + 8 + CACHE_KEY_LEN + PLATFORM_LEN + HASH_LEN + 1
4849
if (section.length < min || !section.subarray(0, MAGIC.length).equals(MAGIC)) {
4950
return null
5051
}
5152
let o = MAGIC.length
52-
const clen = Number(section.readBigUInt64LE(o))
53+
const clenBig = section.readBigUInt64LE(o)
5354
o += 8
54-
const ulen = Number(section.readBigUInt64LE(o))
55+
const ulenBig = section.readBigUInt64LE(o)
5556
o += 8
5657
o += CACHE_KEY_LEN + PLATFORM_LEN
5758
const hash = section.subarray(o, o + HASH_LEN)
@@ -60,13 +61,33 @@ export function unpack(section) {
6061
o += 1192 // config block
6162
}
6263
o += 1
63-
if (!clen || !ulen || ulen > 512 * 1024 * 1024) {
64+
// Bound both lengths BEFORE narrowing to Number, so a huge u64 can't lose
65+
// precision or drive an out-of-range read.
66+
if (
67+
clenBig <= 0n ||
68+
ulenBig <= 0n ||
69+
clenBig > BigInt(MAX_LEN) ||
70+
ulenBig > BigInt(MAX_LEN)
71+
) {
72+
return null
73+
}
74+
const clen = Number(clenBig)
75+
const ulen = Number(ulenBig)
76+
// The declared payload must actually fit in what's left of the section.
77+
if (o + clen > section.length) {
6478
return null
6579
}
6680
const payload = section.subarray(o, o + clen)
6781
if (!crypto.createHash('sha512').update(payload).digest().equals(hash)) {
6882
return null
6983
}
70-
const raw = zlib.zstdDecompressSync(payload)
84+
// A corrupt payload that still matched the hash (or a non-zstd frame) must
85+
// fail closed, never crash the loader.
86+
let raw
87+
try {
88+
raw = zlib.zstdDecompressSync(payload)
89+
} catch {
90+
return null
91+
}
7192
return raw.length === ulen ? raw : null
7293
}

crates/node_binding/scripts/pack-compressed-binding.test.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,31 @@ test('unpack rejects an implausibly large uncompressed size', () => {
6262
packed.writeBigUInt64LE(BigInt(600 * 1024 * 1024), MAGIC.length + 8)
6363
assert.equal(unpack(packed), null)
6464
})
65+
66+
test('unpack rejects a compressed length that overruns the buffer', () => {
67+
const packed = pack(Buffer.from('rspack payload'))
68+
// clen (offset magic+0) claims more bytes than the section actually holds,
69+
// but stays under the 512 MiB cap — the fit check must catch it.
70+
packed.writeBigUInt64LE(BigInt(packed.length + 1024), MAGIC.length)
71+
assert.equal(unpack(packed), null)
72+
})
73+
74+
test('unpack fails closed (no throw) on a non-zstd payload with a valid hash', () => {
75+
// Hand-build a well-formed header whose stored SHA-512 matches the payload,
76+
// but the payload is not a zstd frame — decompress throws, unpack returns null.
77+
const junk = Buffer.from('this is not a zstd frame', 'utf8')
78+
const head = Buffer.alloc(HEAD_LEN)
79+
let o = 0
80+
MAGIC.copy(head, o)
81+
o += MAGIC.length
82+
head.writeBigUInt64LE(BigInt(junk.length), o) // clen
83+
o += 8
84+
head.writeBigUInt64LE(64n, o) // ulen (plausible, under cap)
85+
o += 8
86+
o += 16 + 3 // cache key + platform
87+
crypto.createHash('sha512').update(junk).digest().copy(head, o) // matching hash
88+
o += 64
89+
head[o] = 0 // has_config
90+
const section = Buffer.concat([head, junk])
91+
assert.equal(unpack(section), null)
92+
})

0 commit comments

Comments
 (0)