Safeguard
Application Security

When Node.js sandboxing stops at the C++ boundary

Node's permission model can block a native addon from loading at all — but once it's in, a single buffer overflow in C++ can corrupt the whole process.

Safeguard Research Team
Research
7 min read

N-API arrived as an experimental, ABI-stable interface for building Node.js native addons in Node.js 8.0.0, back in 2017, and reached stable status by Node.js 8.12.0 later that year — the point at which C/C++ modules could finally survive a Node version upgrade without a recompile. That stability made native addons more attractive, not less risky: every addon that loads through N-API runs as native machine code inside the same OS process and memory space as the JavaScript runtime, with the full filesystem, network, and syscall privileges of the Node process itself. V8's sandboxing guarantees — the managed-memory bounds that make a JavaScript bug stay a JavaScript bug — simply don't extend past the FFI boundary. A buffer overflow, a use-after-free, or a type-confusion bug in a native addon can corrupt the V8 heap or crash the process outright, in ways no amount of "the code is just JavaScript" review will ever catch. Node's own Permission Model, promoted out of experimental status with a Stability 2 rating in Node.js 24, added a gate for this: --allow-addons must be explicitly passed before any native addon can load. But that gate is binary. Once an addon is allowed to load, Node's runtime has no further visibility into what the compiled code inside it actually does. This post lays out where that gap comes from and how to review for it.

Why doesn't V8's sandbox protect you once a native addon loads?

V8's sandbox protects JavaScript and WebAssembly execution by enforcing managed-memory bounds — array accesses are checked, objects can't be cast into arbitrary types, and a runtime error throws instead of corrupting memory. None of that applies to code reached through N-API. A native addon is a compiled .node binary (a shared library) that Node dlopens directly into the process; from that point on, its C/C++ code executes with direct, unchecked access to raw memory, exactly as if it were part of Node's own C++ core. There is no interpreter step, no bounds check, no type check between a JS call like addon.parseBuffer(data) and whatever pointer arithmetic that function performs internally. If the addon has a bug — an off-by-one in a loop that copies a Buffer into a fixed-size C array, for instance — that bug behaves exactly like a bug in Node's own source: it corrupts adjacent heap memory, and the failure mode ranges from a segfault to, in the worst case, an exploitable primitive an attacker can chain into arbitrary code execution.

What does Node's permission model actually check for addons?

Node's Permission Model, documented at nodejs.org/api/permissions.html, treats native addons as a single yes/no resource: pass --allow-addons (or set it in a permission-model config) and every addon load in that process is permitted; omit it and process.dlopen throws for all of them. It was previously gated behind --experimental-permission, and as of Node.js 24 the flag is simply --permission with a Stability 2 (Stable) rating in current docs. That's a meaningful control against a script accidentally or maliciously pulling in a native module it shouldn't have access to at all. What it is not is a mediator of what an already-allowed addon does once loaded. There's no per-syscall filtering, no memory-safety check, no sandboxing of the addon's own execution — the model answers "can this addon load," not "is this addon's C++ safe to run." A memory-corruption bug inside an allowed addon is invisible to the permission model by design; it isn't the layer built to catch it.

Why is the native-addon supply chain a distinct risk from pure-JS packages?

Most popular native addons don't compile from source on npm install — they fetch a prebuilt binary from GitHub Releases or an npm-hosted artifact via tooling like node-gyp, prebuild, or prebuildify, matched to your OS, architecture, and Node ABI version. That's a deliberate performance tradeoff: compiling C++ on every install is slow, and prebuilt binaries make installs fast. But it also means the artifact your application actually loads and executes may never have been through the same review as the source checked into the package's GitHub repo — npm install can retrieve a binary blob built by a CI pipeline you don't control, signed by nothing, and there is no equivalent of a JS-level diff to compare it against. A pure-JS package's install-time risk is largely confined to postinstall scripts; a native addon's risk extends to a compiled artifact whose provenance is much harder to verify at all.

What should a security review of a native addon actually check?

A native-addon review needs to answer questions a JS-only SAST scan was never built to ask. First: is the published binary reproducible from the published source, or does the package silently fetch a prebuilt artifact — and if so, from where, and is that source pinned to a commit or a mutable tag? Second: does the addon's C/C++ source (not its JS wrapper) validate buffer lengths and object types on every boundary crossing from JS into native code, since N-API functions like napi_get_buffer_info hand back a raw pointer and length that the addon's own code is responsible for bounds-checking? Third: is the addon compiled with the memory-safety mitigations you'd expect from any C/C++ you ship — AddressSanitizer in CI, stack canaries, fortified source, ASLR-compatible builds — since these are opt-in compiler flags, not defaults node-gyp guarantees. Fourth: does anything in your deployment actually pass --allow-addons deliberately, or did it get added once to unblock an install and never revisited? Treat every native addon as a codebase that happens to be linked into your Node process, because that's precisely what it is.

How should teams reduce this risk in practice?

Start by inventorying which of your dependencies actually ship native addons — a .node file in node_modules is the tell, and it's easy to miss because the addon is usually several transitive levels deep under a package you chose for an unrelated reason. Prefer packages that build from source in CI with reproducible, pinned toolchains over ones that silently pull prebuilt binaries, and pin those addons to exact versions rather than semver ranges, since a patch release can ship a different binary without any change to the JS-visible API. Where the Permission Model is available, scope --allow-addons as narrowly as the runtime allows rather than leaving native loading open by default. And treat a native addon's C/C++ source, when available, as first-class attack surface for manual or tool-assisted review — the memory-corruption bug classes that matter here (CWE-119 buffer overflow, CWE-416 use-after-free, CWE-190 integer overflow) are exactly the ones a JavaScript-focused SAST or dependency scanner has no mechanism to detect, because they don't exist in the language it was built to analyze.

How Safeguard Helps

Safeguard's software composition analysis already inventories every dependency in a Node.js project down to transitive packages, which is the first step toward flagging which of them carry native addons rather than pure JavaScript. Extending that visibility into binary provenance — verifying that a prebuilt .node artifact actually corresponds to its published source, rather than trusting the install-time fetch — is a natural next step for the kind of supply-chain-aware SBOM tracking Safeguard already generates in CycloneDX format for every build. Reachability analysis and Griffin AI's plain-language triage are built to cut through exactly the kind of noisy, hard-to-prioritize finding that native-addon risk produces today: knowing a dependency ships compiled C++ is only useful once you know whether your application's actual call paths ever exercise it.

Never miss an update

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