Safeguard
Security

Buffer Streams: Handling Binary Data Safely in Node.js

Buffer streams are how Node.js moves binary data without loading it all into memory. Here is how they work and the security bugs that hide in buffer handling.

Marcus Chen
DevSecOps Engineer
6 min read

Buffer streams are the pattern Node.js uses to process binary data in chunks through a Buffer moving over a readable or writable stream, so you handle large payloads without loading the whole thing into memory, and the security bugs live in how you size, validate, and combine those chunks. Get buffer handling wrong and you get memory exhaustion, information leaks from uninitialized memory, or injection when buffered bytes are trusted as structure. This guide covers how buffer streams work and where they bite.

Buffers and streams, briefly

A Buffer is Node.js's fixed-length view of raw bytes outside the V8 heap. A stream delivers data as a sequence of Buffer chunks over time. Reading a file as a stream hands you buffers piece by piece rather than one giant allocation:

const fs = require('node:fs');

const stream = fs.createReadStream('large.bin', { highWaterMark: 64 * 1024 });
stream.on('data', (chunk) => {
  // chunk is a Buffer of up to 64 KiB
  process(chunk);
});

The highWaterMark sets the chunk size and the internal buffer threshold. This chunked model is the whole point: a 4 GB file never becomes a 4 GB allocation. The trouble starts when code quietly undoes that by accumulating everything.

Trap one: unbounded accumulation

The most common security bug is buffering an entire request or upload into memory with no size limit, which is a denial-of-service waiting to happen:

// DANGEROUS: no size cap
let chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
  const body = Buffer.concat(chunks);   // attacker controls how big this gets
  handle(body);
});

An attacker sends a multi-gigabyte body and the process runs out of memory. The fix is to enforce a cap and abort early:

const MAX = 5 * 1024 * 1024; // 5 MiB
let size = 0;
const chunks = [];
req.on('data', (c) => {
  size += c.length;
  if (size > MAX) {
    req.destroy();                 // stop reading, free the socket
    return;
  }
  chunks.push(c);
});

Better yet, let the stream stay a stream: pipe uploads straight to disk or to a parser that consumes chunks incrementally, so you never hold the full payload at once.

Trap two: uninitialized memory disclosure

Historically, new Buffer(size) allocated memory without zeroing it, meaning a freshly allocated buffer could contain leftover bytes from previous allocations, including other requests' data. That constructor is deprecated for exactly this reason. Always use the safe factory functions:

const safe = Buffer.alloc(1024);          // zero-filled, safe
const fast = Buffer.allocUnsafe(1024);    // NOT zero-filled, fill it yourself before exposing
const fromData = Buffer.from(input);      // copies from a string/array/buffer

Buffer.alloc zero-fills. Buffer.allocUnsafe is faster but hands you whatever was in memory, so if you allocate it and then serialize it out without fully overwriting it, you can leak process memory to a client. Use allocUnsafe only when you immediately and completely fill the buffer, and default to alloc otherwise.

Trap three: backpressure ignored

Streams have a flow-control mechanism called backpressure. If you write to a slower destination faster than it can accept, an ignored write() return value lets an in-memory queue grow without bound, which is another memory-exhaustion path. Use pipe (or pipeline), which handles backpressure for you:

const { pipeline } = require('node:stream/promises');

await pipeline(
  fs.createReadStream('in.bin'),
  transformStream,
  fs.createWriteStream('out.bin')
);

pipeline also propagates errors and cleans up all streams on failure, avoiding the dangling file descriptors and half-written outputs that hand-wired pipe chains tend to leave behind.

Trap four: trusting buffered bytes as structure

When buffered bytes come from a network and get interpreted, treat the boundary carefully. Two recurring issues:

  • Encoding assumptions. Decoding a buffer with the wrong charset, or slicing a multibyte character across a chunk boundary, corrupts data and can bypass validators that inspected the raw bytes. When concatenating chunks that form text, join first with Buffer.concat and decode once, rather than decoding each chunk.
  • Length and offset math. Manual buf.slice(offset, offset + len) with an attacker-influenced len can read past intended bounds or produce empty/oversized reads. Validate lengths against the buffer size before slicing, and prefer higher-level parsers over hand-rolled binary parsing.

Parsing untrusted binary formats by hand is a classic source of memory-safety and logic bugs. Where you can, use a maintained parser library, and then make sure that library is itself tracked for vulnerabilities. An SCA tool such as Safeguard flags known issues in stream and parser dependencies so a bug in the library you leaned on to be safe does not become your bug. The SCA product page covers how those advisories surface.

Practical checklist

  • Cap the total bytes you will buffer from any untrusted source; destroy the stream when exceeded.
  • Use Buffer.alloc, never new Buffer(); reserve allocUnsafe for buffers you fully overwrite.
  • Prefer pipeline over manual pipe so backpressure and cleanup are handled.
  • Decode text once after concatenation, not per chunk, to avoid split-character bugs.
  • Validate all offsets and lengths before slicing.

For the broader picture of how untrusted input becomes a vulnerability across a service, the input-handling fundamentals material connects buffer bugs to the wider injection and resource-exhaustion classes.

FAQ

What are buffer streams in Node.js?

They are the pattern of processing binary data as a series of Buffer chunks flowing through a readable or writable stream. This lets you handle large payloads incrementally instead of loading everything into memory at once.

Why is new Buffer() considered unsafe?

The legacy new Buffer(size) constructor allocated memory without zeroing it, so a buffer could contain leftover bytes from prior allocations and leak them. It is deprecated; use Buffer.alloc for zero-filled memory or Buffer.allocUnsafe only when you fully overwrite the buffer immediately.

How do buffer streams cause denial-of-service bugs?

By accumulating an entire untrusted payload into memory with no size limit. An attacker sends a huge body and the process exhausts memory. Enforce a maximum byte cap and destroy the stream when it is exceeded.

What is backpressure and why does it matter for security?

Backpressure is a stream's flow-control signal that a destination cannot keep up. Ignoring it lets an internal queue grow unbounded, another memory-exhaustion path. Using pipeline or pipe handles backpressure automatically.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.