no-unsafe-buffer-alloc
Disallow `Buffer.allocUnsafe()` and `Buffer.allocUnsafeSlow()`, which return uninitialized memory.
Disallow Buffer.allocUnsafe() and Buffer.allocUnsafeSlow(). Both hand back a buffer over memory that was never zeroed — until every byte is overwritten, the buffer contains whatever the allocator last stored there.
This rule is part of eslint-plugin-node-security.
Quick Summary
| Aspect | Details |
|---|---|
| Severity | High (Security — memory disclosure) |
| Auto-Fix | 💡 Suggestion (Buffer.alloc) |
| Category | Node Security |
| CWE | CWE-908 |
| Recommended | warn — reports unless coverage is structurally visible (see below) |
| Best For | Codebases that send, hash, or persist buffers built by hand |
What the rule reports, and what it lets through
The hazard is a byte that is read before it is written. A buffer that is filled before anything looks at it discloses nothing, so the rule stays silent on two shapes and reports everything else.
1. Zeroed in the same expression. A parent-node check, not variable tracking.
Buffer.allocUnsafe(64).fill(0); // not reported — equivalent to Buffer.alloc(64)2. Covered before it is read. The allocation is bound to a local, and the first thing that touches that local is a covering write.
// Not reported — the loop writes every byte before `result` escapes.
// (redis/ioredis lib/Command.ts:667)
const result = Buffer.allocUnsafe(this.length);
let offset = 0;
for (const item of this.items) {
const length = Buffer.byteLength(item);
Buffer.isBuffer(item)
? item.copy(result, offset)
: result.write(item, offset, length);
offset += length;
}
return result;"Covering" is narrower than "any write", because proving full coverage is undecidable and the rule refuses to pretend otherwise. Two shapes cover by construction:
| Shape | Covering? | Why |
|---|---|---|
buf.fill(v) | ✅ | fills to the end of the buffer by definition |
src.copy(buf), buf.set(src), buf.write(s) | ✅ | one argument: starts at 0, runs for the source's whole length |
buf.write(s, 'utf8'), buf.fill(v, 'hex') | ✅ | a string in the offset position is the encoding; still starts at 0 |
crypto.randomFillSync(buf), crypto.getRandomValues(buf) | ✅ | fills its first argument (also as a named import from node:crypto) |
buf.write(s, offset) / src.copy(buf, offset) inside a loop | ✅ | a moving offset is a buffer being walked |
buf.fill(0, 4) | ❌ | starts at 4: bytes 0..3 stay uninitialized |
buf.writeUInt32BE(v, 0) | ❌ | a fixed offset stamps one field and leaves the rest |
src.copy(buf, 0, 0, 4) | ❌ | a fixed slice |
buf[i] = v | ❌ | one byte |
A partial write does not end the search — it is not a read either — so a
fixed-offset write followed by a covering one is still accepted. Fixed-width
writes (writeUInt8 … writeBigUInt64BE) at decidable offsets — a numeric
literal or a const alias of one — are counted byte by byte on an allocation of
constant size, so a header stamped field by field until every byte is written
is covered. A read reached
before any covering write is reported.
The binding may receive the allocation in a declarator (const buf = …) or an
assignment, including the lazy forms (scratch ??= Buffer.allocUnsafe(8)).
Anything the rule cannot resolve to a local binding still reports:
return Buffer.allocUnsafe(n), this.buf = Buffer.allocUnsafe(n),
f(Buffer.allocUnsafe(n)). References are compared in source order, which
is an approximation of execution order: a buffer written inside a callback
declared above its first read is judged on where the text sits, not on when it
runs.
For the complementary read-side analysis — buffers read past their written
region (CWE-126) — see
no-buffer-overread.
The CWE-789 half: allocation sized off the wire
The same rule reports new Array(n), Buffer.alloc(n) and the typed-array
constructors when n is a length field a peer chose. The mechanism is not the
one this documentation used to claim ("a large length makes the process
allocate and then walk a huge structure"). Measured on V8 (node 24,
--expose-gc, heapUsed delta):
new Array(1e9) -> 0.003 ms, 0.0 MB // dictionary mode — free
new Array(3e7) -> 20.700 ms, 228.9 MB // packed elements — 12 wire bytes buy 229 MB
new Array(4e7) -> 0.007 ms, 0.0 MB // flips back to dictionary modeThe cost is a narrow band below V8's packed-elements limit (~33.5M
elements), not a monotonic function of n. That inverts the usual advice: a
bounds check that rejects only implausibly large lengths rejects exactly the
values that cost nothing, and admits the ones that cost a quarter of a
gigabyte. Clamp against the maximum the protocol permits, not against a
number that merely looks big.
Buffer.alloc(n) and the typed arrays have no such threshold — they commit n
bytes at every n.
Array(n) without new is the same allocation and is reported the same way. A
size counts as off the wire when it is read off a request, off a binding named
like wire data whose origin is invisible (a parameter, an import, a for…of
element), or passed into a parameter — of a function, or of a class
constructor through new — that some call in the file feeds wire data. Local
bindings are followed through their last write however many hops long the
chain is. Math.min(size, MAX) with a bound the peer cannot move counts as a
clamp; Math.max does not. A guard counts only when it caps the size from
above against such a bound: an enclosing if (size < MAX) / if (size <= MAX)
(or the else of if (size > MAX)), or an earlier if (size > MAX) throw /
return in an enclosing block. A lower-bound check (if (size > 0), rejecting
size < 1) lets every huge size through and is not a guard.
Why allocUnsafe is dangerous
Buffer.alloc(size) zero-fills. Buffer.allocUnsafe(size) skips that step and
returns a slice of a pre-allocated pool, which is why it is faster. The bytes it
returns are whatever the previous owner of that memory left behind: decrypted
request bodies, session tokens, private keys.
Any byte not overwritten before the buffer is read, hashed, written to disk, or
sent over the network discloses that memory to whoever receives it. This is the
same class of defect as CVE-2018-7166,
where Node's own randomFillSync fallback returned unzeroed memory.
Buffer.allocUnsafeSlow(size) has the identical hazard — it only differs in
allocating outside the shared pool.
| Allocator | Zero-filled | Reported by this rule |
|---|---|---|
Buffer.alloc(size) | ✅ yes | no |
Buffer.allocUnsafe(size) | ❌ no | yes |
Buffer.allocUnsafeSlow(size) | ❌ no | yes |
Buffer.allocUnsafe(size).fill(0) | ✅ yes | no (structural exemption) |
const b = Buffer.allocUnsafe(n); src.copy(b); | ✅ effectively | no (covered before read) |
Examples
❌ Incorrect
const buf = Buffer.allocUnsafe(1024);
res.end(buf); // leaks prior heap contents
const header = Buffer.allocUnsafe(16);
header.writeUInt32BE(len, 0); // 12 bytes still uninitialized
socket.write(header); // …and they go out on the wire
const slow = Buffer.allocUnsafeSlow(64);✅ Correct
const buf = Buffer.alloc(1024); // zero-filled
const header = Buffer.alloc(16);
header.writeUInt32BE(len, 0); // remaining bytes are zeros
const scratch = Buffer.allocUnsafe(64).fill(0); // zeroed at the call site
const copy = Buffer.allocUnsafe(src.length); // covered before any read
src.copy(copy);
send(copy);Options
| Option | Type | Default | Description |
|---|---|---|---|
wireNames | string[] | ["chunk","chunks","buffer","buf","payload","frame","packet","raw","message","msg"] | Binding names that carry bytes off the wire. Replaces the default; pass [] to turn the name arm off. |
requestRootNames | string[] | ["req","request","ctx","event"] | Root identifiers treated as a request. Replaces the default. |
countNames | string[] | ["length","len","size","count","n","num","total","capacity","bytelength"] | Identifiers that hold an allocation size rather than a payload. Replaces the default. |
Error Message Format
🔒 NODE-SECURITY CWE-908 | Uninitialized Buffer Allocation | HIGH
Fix: Use `Buffer.alloc(size)` (zero-filled), or keep `allocUnsafe` only where
the very next statement overwrites the whole buffer.When Not To Use It
Turn the rule off (or scope it with an override) in code where allocUnsafe is
a measured, reviewed performance decision and coverage is proved by an argument
the rule cannot see — a pool whose bookkeeping lives in another module, or a
codec that hands the buffer to native code to fill.
Known False Positives
- An allocation covered through a shape the rule does not model: filled by a
helper (
fillHeader(buf)), by a native binding, or across a module boundary. These stay reported; unresolved is not the same as safe. - Coverage proved only by execution order that differs from source order — a buffer written inside a callback declared above its first read.
Known False Negatives
- Dynamic dispatch (
global['Buffer'].allocUnsafe(n)) is not resolved; a computed key that resolves to a constant (Buffer['allocUnsafe'](n)) is. - Member aliases (
const alloc = Buffer.allocUnsafe; alloc(n);) are not tracked; a destructured one (const { allocUnsafe } = Buffer) is. - Buffer shims and polyfills that ship their own
allocUnsafeare out of scope. - A loop write at a moving offset is treated as covering. A loop that writes
only part of the buffer — an early
break, a bound smaller than the allocation — is accepted on a coverage argument the rule did not check.
Landscape
| Package | Rule | Behavior |
|---|---|---|
eslint-plugin-security-node | detect-buffer-unsafe-allocation | Unconditional; ships recommended: false |
@microsoft/eslint-plugin-sdl | no-unsafe-alloc | Unconditional |
eslint-plugin-node-security | no-unsafe-buffer-alloc | allocUnsafeSlow too, the .fill() exemption, a covered-before-read check that clears the whole-buffer-copy idiom, a Buffer.alloc suggestion, and the CWE-789 wire-sized-allocation check |
Use this rule
npm install -D oxlint-plugin-node-security{ "jsPlugins": ["oxlint-plugin-node-security"], "rules": { "node-security/no-unsafe-buffer-alloc": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-node-security'], rules: { 'node-security/no-unsafe-buffer-alloc': 'error', }, },});oxlint-plugin-node-security re-exports eslint-plugin-node-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.