Skip to main content

The core constraint

Sentinel runs inside the same Node.js process as every third-party package it protects against. There is no sandbox, no separate process, and no OS-level isolation — everything happens in one heap. Any built-in function that Sentinel relies on can be overwritten by the code it is trying to guard, and any guard can be stripped if it is not locked down explicitly. Meaningful enforcement in this environment requires layered hardening: each layer closes bypasses that would exist if only the others were active.

The five-layer model

LayerTechniqueWhat it closes
0 — Prototype hardeningObject.preventExtensions on all built-in prototypesPrototype pollution before any third-party code runs
1 — Module interceptionModule._load hook + non-configurable lockrequire() of fs, http, child_process, vm, worker_threads
2 — Node isolationES6 Proxy on every getNode() return valueProperty reads, writes, and defineProperty on live node instances
3 — Surface hardeningGuarded Express routing, process.env Proxy, router-stack ProxyPost-init manipulation of the HTTP server and environment
4 — Network policyOutbound HTTP/HTTPS/socket allowlistExfiltration paths not covered by the module gate
Cross-cuttingIntrinsic capture, call-stack introspection, file integrity watchdogPrototype mutation of guard helpers, call-identity forgery, on-disk tampering

Cross-cutting techniques

The three cross-cutting concerns are prerequisites for every layer, not layers in their own right.

Intrinsic capture

All built-in methods that guard logic depends on are pinned as standalone bound functions before the first require() runs:
var $strIncludes  = Function.prototype.call.bind(String.prototype.includes);
var $arrIndexOf   = Function.prototype.call.bind(Array.prototype.indexOf);
var $setHas       = Function.prototype.call.bind(Set.prototype.has);
// … and so on for every method the guards use …
Function.prototype.call.bind(Method) returns a new function holding a direct reference to the original native implementation captured at bind time. Any subsequent mutation of the prototype property has zero effect on the saved alias.
If intrinsic capture did not happen first, a malicious package could overwrite String.prototype.includes to always return false, blinding every stack-frame classification check in one line:
String.prototype.includes = function () { return false; };
Because the captures are the very first statements of the preload IIFE — before any require() — no third-party code has had a chance to run by that point.

IIFE + strict mode wrapper

The entire preload is wrapped in an immediately-invoked function expression with "use strict" at its top:
(function () {
    "use strict";
    // … all guard code …
})();
This matters for two reasons:
  • Scope isolation. Variables declared inside the IIFE (allowMap, _blockedEvents, captured intrinsics) are not reachable by inspecting the global object. A third-party package cannot locate and mutate Sentinel’s internal state.
  • Strict mode semantics. with statements (which can shadow identifiers and confuse static analysis) are disabled. Accidental global variable creation throws a TypeError. this inside a plain function call is undefined rather than the global object, eliminating a class of unintentional global mutation.

Call-stack introspection

Every capability check needs to know which package is making the call. Sentinel reads the V8 call stack from new Error().stack, parses each frame’s file path against the node_modules/<pkg> pattern, and identifies the first frame that belongs to a user-installed package in the Node-RED userDir:
var frames = $arrSlice($strSplit(new Error().stack, "\n"), 3, 20);
for (var i = 0; i < frames.length; i++) {
    if ($strIncludes(frames[i], "@node-red/")) continue;    // trusted NR core
    if ($strIncludes(frames[i], "/node_modules/express/")) continue;
    if ($strIncludes(frames[i], "@allanoricil/nrg-sentinel")) continue;
    var mod = extractModuleFromFrame(frames[i]);
    if (mod && isFromUserDir(frames[i])) return mod;        // first user frame
}
The call stack is generated by the V8 runtime and cannot be spoofed from JavaScript running in the same context. Function arguments do not carry identity, and any identity the calling package supplies itself could be forged — making the stack walk the only reliable attribution mechanism available in-process.
Error.stackTraceLimit is temporarily raised to 30 around every stack walk and restored in a finally block, so deeply nested call chains cannot push the attacker’s frame beyond the examined range.
Frames from @node-red/*, the unscoped node-red package, Express, and Node.js internals are skipped as trusted. Truly anonymous frames (from new Function() or eval, which have the form at fn (<anonymous>:1:3)) are treated as untrusted — preventing the technique of wrapping a malicious call in new Function("...")() to confuse the walk.

Layer 1: The Module._load lock

Sentinel replaces Module._load with its own wrapper, then immediately locks the property:
Object.defineProperty(Module, "_load", {
    value:        Module._load,   // the Sentinel wrapper
    writable:     false,
    configurable: false,
});
Setting configurable: false is essential. Without it, a malicious package loaded early in the process could overwrite Module._load with the original unguarded function, stripping every subsequent module interception check. Making the property both non-writable (cannot be assigned) and non-configurable (cannot be redefined via Object.defineProperty) closes both bypass paths.

Layer 2: Node proxy with defineProperty trap

Every node object returned by RED.nodes.getNode() is wrapped in an ES6 Proxy with three traps:
return new Proxy(realNode, {
    get:            function (target, prop) { /* capability check */ },
    set:            function (target, prop, value) { /* capability check */ },
    defineProperty: function (target, prop, descriptor) { /* capability check */ },
});
The defineProperty trap is not optional. Without it, Object.defineProperty(node, 'credentials', getter) would bypass the set trap entirely — hitting the defineProperty trap instead, which if absent allows the call through unconditionally. All three traps must be present to close the node-object bypass surface.

File integrity watchdog

A setInterval running every 30 seconds re-hashes Sentinel’s own preload.js and plugin.js and compares against startup baselines. It also checks the file permission mode:
setInterval(function () {
    // 1. Permission mode check — a write-bit appearing on a read-only file
    //    is a pre-tampering signal (attacker must chmod before editing).
    if (_shouldBeReadOnly && (fs.statSync(sentinelPath).mode & 0o222)) {
        console.error("PERMISSION CHANGE DETECTED!");
    }
    // 2. Content hash check
    if (computeFileHash(sentinelPath) !== _sentinelHash) {
        console.error("FILE TAMPERING DETECTED!");
    }
}, 30000).unref();
The watchdog catches supply-chain attacks where a threat actor gains write access to the server’s filesystem (for example, through a misconfigured volume mount) and edits preload.js directly to remove guards. The permission-mode check provides earlier warning: an attacker must chmod before editing, and the mode change appears in the next 30-second cycle before the hash changes. .unref() ensures the watchdog interval does not prevent the Node.js process from exiting cleanly when Node-RED shuts down.

How the layers compose

Each layer closes a bypass that would exist if only the others were active:
Attacker actionBlocked by
Overwrite String.prototype.includes to blind stack checksIntrinsic capture
Inject Object.prototype.admin = true to forge capability grantsPrototype hardening (layer 0)
Call require('fs') directlyModule._load interception (layer 1)
Overwrite Module._load to strip the hookModule._load lock (layer 1)
Call Object.defineProperty(node, 'credentials', getter)Node Proxy defineProperty trap (layer 2)
Wrap a call in new Function(...)() to confuse the stack walkAnonymous-frame detection in isInternalCaller()
Splice a route handler into app._router.stackRouter-stack Proxy (layer 3)
Edit preload.js on disk to remove guardsFile integrity watchdog
Read process.env.NODE_RED_CREDENTIAL_SECRETprocess.env Proxy (layer 3)
Call vm.runInNewContext(...) to escape Module._loadrequire('vm') blocked at layer 1

Build docs developers (and LLMs) love