npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@allanoricil/nrg-sentinel

v1.3.0

Published

Node-RED Runtime Security Hardening

Readme

NRG Sentinel

A security layer for Node-RED that detects and blocks common attack vectors at runtime without modifying the Node-RED core.

E2E Test Results

The table below is updated automatically after each CI run on main.

| # | Demo | Result | Node-RED | |---|------|:------:|:--------:| | 01 | Monkey Patching | ✅ | 4.1.8 | | 02 | Hook Injection | ✅ | 4.1.8 | | 03 | Credential Theft | ✅ | 4.1.8 | | 04 | Wire Manipulation | ✅ | 4.1.8 | | 05 | Direct Receive Injection | ✅ | 4.1.8 | | 06 | Express Middleware | ✅ | 4.1.8 | | 07 | EventEmitter Hijack | ✅ | 4.1.8 | | 08 | Node Enumeration | ✅ | 4.1.8 | | 09 | Prototype Pollution | ✅ | 4.1.8 | | 10 | Flow File Tampering | ✅ | 4.1.8 | | 11 | Message Provenance | ✅ | 4.1.8 | | 12 | Settings.js Tampering | ✅ | 4.1.8 | | 13 | Sentinel Source Tampering | ✅ | 4.1.8 | | 14 | Express Route Backdoor | ✅ | 4.1.8 | | 15 | Config Node Z-Forgery | ✅ | 4.1.8 | | 16 | Symbol Property Bypass | ✅ | 4.1.8 | | 17 | EventEmitter Enumeration | ✅ | 4.1.8 | | 18 | Deep Stack Bypass | ✅ | 4.1.8 | | 19 | HTTP Route Deletion | ✅ | 4.1.8 | | 20 | Child Process Exec | ✅ | 4.1.8 | | 21 | SW Fetch Interception | — | — | browser-only — verify via start-interactive.sh | | 22 | FS Read | ✅ | 4.1.8 | | 23 | Process Env Exfiltration | ✅ | 4.1.8 | | 24 | Process Exit DoS | ✅ | 4.1.8 | | 25 | VM Sandbox Escape | ✅ | 4.1.8 | | 26 | Worker Thread Escape | ✅ | 4.1.8 | | 27 | Network Socket Exfiltration | ✅ | 4.1.8 | | 28 | Registry Type Hijack | ✅ | 4.1.8 | | 29 | Settings Mutation | ✅ | 4.1.8 | | 30 | Comms Publish Spoofing | ✅ | 4.1.8 | | 31 | Context Permissions | ✅ | 4.1.8 | | 32 | Flows Inject | ✅ | 4.1.8 | | 33 | Node Event Hijack | ✅ | 4.1.8 | | 34 | Config Node Credentials | ✅ | 4.1.8 | | 35 | Process Binding Bypass | ✅ | 4.1.8 | | 36 | ChildProcess Proto Bypass | ✅ | 4.1.8 | | 37 | UserDir Bypass | ✅ | 4.1.8 | | 38 | Exec Binary Allowlist | ✅ | 4.1.8 | | 39 | Native Addon Guard | ✅ | 4.1.8 | | 40 | Native WASM Guard | ✅ | 4.1.8 | | 41 | Module Cache Poison | ✅ | 4.1.8 | | 42 | HTTP Route Tamper | ✅ | 4.1.8 | | 43 | Symlink NPM Install | ✅ | 4.1.8 | | 44 | FS Read Cap Config | ✅ | 4.1.8 | | 45 | FS Write Cap Config | ✅ | 4.1.8 | | 46 | Flows File Protected | ✅ | 4.1.8 | | 47 | Per-Package Cap Config | ✅ | 4.1.8 | | 48 | Grants Merge | ✅ | 4.1.8 | | 49 | Network HTTP Allowlist | ✅ | 4.1.8 | | 50 | Exec Binary Union | ✅ | 4.1.8 | | 51 | Types Grant | ✅ | 4.1.8 | | 52 | Network TCP | ✅ | 4.1.8 | | 53 | Network UDP | ✅ | 4.1.8 | | 54 | Network DNS | ✅ | 4.1.8 | | 55 | Network WebSocket | ✅ | 4.1.8 | | 56 | Network Listen | ✅ | 4.1.8 | | 57 | HTTP Handler Hardening | ✅ | 4.1.8 | | 58 | FS Require Variants | ✅ | 4.1.8 | | 59 | ESM Process Exec | ✅ | 4.1.8 |

| # | Demo | Result | Node-RED | |---|------|:------:|:--------:| | 01 | Monkey Patching | ✅ | 4.1.8 | | 02 | Hook Injection | ✅ | 4.1.8 | | 03 | Credential Theft | ✅ | 4.1.8 | | 04 | Wire Manipulation | ✅ | 4.1.8 | | 05 | Direct Receive Injection | ✅ | 4.1.8 | | 06 | Express Middleware | ✅ | 4.1.8 | | 07 | EventEmitter Hijack | ✅ | 4.1.8 | | 08 | Node Enumeration | ✅ | 4.1.8 | | 09 | Prototype Pollution | ✅ | 4.1.8 | | 10 | Flow File Tampering | ✅ | 4.1.8 | | 11 | Message Provenance | ✅ | 4.1.8 | | 12 | Settings.js Tampering | ✅ | 4.1.8 | | 13 | Sentinel Source Tampering | ✅ | 4.1.8 | | 14 | Express Route Backdoor | ✅ | 4.1.8 | | 15 | Config Node Z-Forgery | ✅ | 4.1.8 | | 16 | Symbol Property Bypass | ✅ | 4.1.8 | | 17 | EventEmitter Enumeration | ✅ | 4.1.8 | | 18 | Deep Stack Bypass | ✅ | 4.1.8 | | 19 | HTTP Route Deletion | ✅ | 4.1.8 | | 20 | Child Process Exec | ✅ | 4.1.8 | | 21 | SW Fetch Interception | — | — | browser-only — verify via start-interactive.sh | | 22 | FS Read | ✅ | 4.1.8 | | 23 | Process Env Exfiltration | ✅ | 4.1.8 | | 24 | Process Exit DoS | ✅ | 4.1.8 | | 25 | VM Sandbox Escape | ✅ | 4.1.8 | | 26 | Worker Thread Escape | ✅ | 4.1.8 | | 27 | Network Socket Exfiltration | ✅ | 4.1.8 | | 28 | Registry Type Hijack | ✅ | 4.1.8 | | 29 | Settings Mutation | ✅ | 4.1.8 | | 30 | Comms Publish Spoofing | ✅ | 4.1.8 | | 31 | Context Permissions | ✅ | 4.1.8 | | 32 | Flows Inject | ✅ | 4.1.8 | | 33 | Node Event Hijack | ✅ | 4.1.8 | | 34 | Config Node Credentials | ✅ | 4.1.8 | | 35 | Process Binding Bypass | ✅ | 4.1.8 | | 36 | ChildProcess Proto Bypass | ✅ | 4.1.8 | | 37 | UserDir Bypass | ✅ | 4.1.8 | | 38 | Exec Binary Allowlist | ✅ | 4.1.8 | | 39 | Native Addon Guard | ✅ | 4.1.8 | | 40 | Native WASM Guard | ✅ | 4.1.8 | | 41 | Module Cache Poison | ✅ | 4.1.8 | | 42 | HTTP Route Tamper | ✅ | 4.1.8 | | 43 | Symlink NPM Install | ✅ | 4.1.8 | | 44 | FS Read Cap Config | ✅ | 4.1.8 | | 45 | FS Write Cap Config | ✅ | 4.1.8 | | 46 | Flows File Protected | ✅ | 4.1.8 | | 47 | Per-Package Cap Config | ✅ | 4.1.8 | | 48 | Grants Merge | ✅ | 4.1.8 | | 49 | Network HTTP Allowlist | ✅ | 4.1.8 | | 50 | Exec Binary Union | ✅ | 4.1.8 | | 51 | Types Grant | ✅ | 4.1.8 | | 52 | Network TCP | ✅ | 4.1.8 | | 53 | Network UDP | ✅ | 4.1.8 | | 54 | Network DNS | ✅ | 4.1.8 | | 55 | Network WebSocket | ✅ | 4.1.8 | | 56 | Network Listen | ✅ | 4.1.8 | | 57 | HTTP Handler Hardening | ✅ | 4.1.8 | | 58 | FS Require Variants | ✅ | 4.1.8 | | 59 | ESM Process Exec | ✅ | 4.1.8 |

| # | Demo | Result | Node-RED | |---|------|:------:|:--------:| | 01 | Monkey Patching | ✅ | 4.1.8 | | 02 | Hook Injection | ✅ | 4.1.8 | | 03 | Credential Theft | ✅ | 4.1.8 | | 04 | Wire Manipulation | ✅ | 4.1.8 | | 05 | Direct Receive Injection | ✅ | 4.1.8 | | 06 | Express Middleware | ✅ | 4.1.8 | | 07 | EventEmitter Hijack | ✅ | 4.1.8 | | 08 | Node Enumeration | ✅ | 4.1.8 | | 09 | Prototype Pollution | ✅ | 4.1.8 | | 10 | Flow File Tampering | ✅ | 4.1.8 | | 11 | Message Provenance | ✅ | 4.1.8 | | 12 | Settings.js Tampering | ✅ | 4.1.8 | | 13 | Sentinel Source Tampering | ✅ | 4.1.8 | | 14 | Express Route Backdoor | ✅ | 4.1.8 | | 15 | Config Node Z-Forgery | ✅ | 4.1.8 | | 16 | Symbol Property Bypass | ✅ | 4.1.8 | | 17 | EventEmitter Enumeration | ✅ | 4.1.8 | | 18 | Deep Stack Bypass | ✅ | 4.1.8 | | 19 | HTTP Route Deletion | ✅ | 4.1.8 | | 20 | Child Process Exec | ✅ | 4.1.8 | | 21 | SW Fetch Interception | — | — | browser-only — verify via start-interactive.sh | | 22 | FS Read | ✅ | 4.1.8 | | 23 | Process Env Exfiltration | ✅ | 4.1.8 | | 24 | Process Exit DoS | ✅ | 4.1.8 | | 25 | VM Sandbox Escape | ✅ | 4.1.8 | | 26 | Worker Thread Escape | ✅ | 4.1.8 | | 27 | Network Socket Exfiltration | ✅ | 4.1.8 | | 28 | Registry Type Hijack | ✅ | 4.1.8 | | 29 | Settings Mutation | ✅ | 4.1.8 | | 30 | Comms Publish Spoofing | ✅ | 4.1.8 | | 31 | Context Permissions | ✅ | 4.1.8 | | 32 | Flows Inject | ✅ | 4.1.8 | | 33 | Node Event Hijack | ✅ | 4.1.8 | | 34 | Config Node Credentials | ✅ | 4.1.8 | | 35 | Process Binding Bypass | ✅ | 4.1.8 | | 36 | ChildProcess Proto Bypass | ✅ | 4.1.8 | | 37 | UserDir Bypass | ✅ | 4.1.8 | | 38 | Exec Binary Allowlist | ✅ | 4.1.8 | | 39 | Native Addon Guard | ✅ | 4.1.8 | | 40 | Native WASM Guard | ✅ | 4.1.8 | | 41 | Module Cache Poison | ✅ | 4.1.8 | | 42 | HTTP Route Tamper | ✅ | 4.1.8 | | 43 | Symlink NPM Install | ✅ | 4.1.8 | | 44 | FS Read Cap Config | ✅ | 4.1.8 | | 45 | FS Write Cap Config | ✅ | 4.1.8 | | 46 | Flows File Protected | ✅ | 4.1.8 | | 47 | Per-Package Cap Config | ✅ | 4.1.8 | | 48 | Grants Merge | ✅ | 4.1.8 | | 49 | Network HTTP Allowlist | ✅ | 4.1.8 | | 50 | Exec Binary Union | ✅ | 4.1.8 | | 51 | Types Grant | ✅ | 4.1.8 | | 52 | Network TCP | ✅ | 4.1.8 | | 53 | Network UDP | ✅ | 4.1.8 | | 54 | Network DNS | ✅ | 4.1.8 | | 55 | Network WebSocket | ✅ | 4.1.8 | | 56 | Network Listen | ✅ | 4.1.8 | | 57 | HTTP Handler Hardening | ✅ | 4.1.8 | | 58 | FS Require Variants | ✅ | 4.1.8 | | 59 | ESM Process Exec | ✅ | 4.1.8 |

Last updated: 2026-04-19T02:07:07Z

Demos

Each demo is a self-contained scenario that shows an attack against Node-RED and how Sentinel blocks it.

| # | Demo | Attack vector | | --- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | 01 | Monkey Patching | Overwrites Node-RED core functions at runtime | | 02 | Hook Injection | Registers malicious onSend/onReceive hooks | | 03 | Credential Theft | Reads decrypted credentials from live node instances | | 04 | Wire Manipulation | Rewires flow connections to exfiltrate data | | 05 | Direct Receive Injection | Bypasses auth chain via node.receive() | | 06 | Express Middleware | Installs rogue HTTP middleware on the admin API | | 07 | EventEmitter Hijack | Intercepts internal Node-RED events | | 08 | Node Enumeration | Maps every node in the runtime via eachNode() | | 09 | Prototype Pollution | Pollutes Object.prototype to affect all objects | | 10 | Flow File Tampering | Modifies the flows file on disk | | 11 | Message Provenance | Detects and blocks injected messages via HMAC tagging | | 12 | Settings.js Tampering | Modifies settings.js at runtime to inject capability grants | | 13 | Sentinel Source Tampering | Patches Sentinel's preload.js on disk to disable protection | | 14 | Express Route Backdoor | Registers a hidden admin API route via httpAdmin.get() | | 15 | Config Node Z-Forgery | Fakes config-node identity to bypass credential access rules | | 16 | Symbol Property Bypass | Uses Symbol-keyed properties to evade proxy guard interception | | 17 | EventEmitter Enumeration | Enumerates all RED.events listeners to map internal runtime wiring | | 18 | Deep Stack Bypass | Chains anonymous wrappers to push the malicious frame outside the guard window | | 19 | HTTP Route Deletion | Deletes existing Express routes to disable authentication endpoints | | 20 | Child Process Exec | Spawns a shell command via child_process to execute arbitrary OS commands | | 21 | SW Fetch Interception | Browser-only: editor script uses fetch() to exfiltrate data; Service Worker blocks it via the network-policy allowlist | | 22 | FS Read | Reads settings.js via require('fs') to extract the credential secret | | 23 | Process Env Exfiltration | Reads process.env to harvest injected secrets and API keys | | 24 | Process Exit DoS | Calls process.exit() from a message handler to kill the runtime | | 25 | VM Sandbox Escape | Uses require('vm') to run code outside Sentinel's Module._load hooks | | 26 | Worker Thread Escape | Spawns a worker thread whose module loader is invisible to Sentinel | | 27 | Network Socket Exfiltration | Creates a raw TCP socket to bypass the HTTP URL allowlist | | 28 | Registry Type Hijack | Calls registerType('inject', ...) to silently replace a built-in node type | | 29 | Settings Mutation | Reads or writes RED.settings to extract the credential secret or add backdoors | | 30 | Comms Publish Spoofing | Pushes fake notifications to the editor via RED.comms.publish() | | 31 | Context Permissions | Reads or writes another node's context store without a grant | | 32 | Flows Inject | Injects a malicious node into the running flow via the flows API | | 33 | Node Event Hijack | Spies on or silences another node's input handler via EventEmitter APIs | | 34 | Config Node Credentials | Interactive: explores open / restricted / locked config-node credential access | | 35 | Process Binding Bypass | Uses process.binding('spawn_sync') to spawn processes, bypassing JS-level guards | | 36 | ChildProcess Proto Bypass | Calls ChildProcess.prototype.spawn() directly, bypassing module-export guards | | 37 | UserDir Bypass | Launches Node-RED with -u <path> instead of env vars, so Sentinel defaults to ~/.node-red and misidentifies all attacker frames as internal |

Capability grants

By default Sentinel blocks every privileged operation for every third-party package. A package that needs a capability must be explicitly granted it in settings.js.

For the complete capability reference — every capability string, what it gates, shorthand expansions, and known gaps — see docs/capability-design.md.

Capability quick-reference

| Group | Atomic capabilities | Shorthands | |---|---|---| | node:* | node:read, node:write, node:send, node:status, node:log, node:close, node:receive, node:events:subscribe, node:events:unsubscribe, node:list, node:wires:read, node:wires:write, node:credentials:read, node:credentials:write, node:credentials:delete, node:context:read, node:context:write | node:events, node:wires, node:credentials, node:context, node:all | | flows:* | flows:read, flows:write, flows:delete, flows:start, flows:stop | flows:all | | storage:* | storage:read, storage:write | storage:all | | registry:* | registry:write, registry:read | registry:all | | settings:* | settings:read, settings:write | settings:all | | hooks:* | hooks:on-send, hooks:pre-route, hooks:pre-deliver, hooks:post-deliver, hooks:on-receive, hooks:post-receive, hooks:on-complete, hooks:remove | hooks:message (all pipeline hooks), hooks:all | | http:* | http:admin (prefixed), http:admin:global (original paths), http:admin:global:middleware (no-path/config), http:node, http:node:global, http:node:global:middleware | — | | events:* | events:subscribe:<name>, events:subscribe, events:emit:<name>, events:emit, events:unsubscribe:<name>, events:unsubscribe | — | | process:* | process:exec, process:env:read, process:env:write, process:exit | process:env, process:all | | fs:* | fs:read, fs:write | fs:all | | network:* | network:http, network:tcp, network:udp, network:ws, network:dns, network:listen | network:all | | vm:* | vm:execute | — | | threads:* | threads:spawn | — | | native:* | native:addon, native:wasm | — | | comms:* | comms:publish | — | | top-level | — | all (every cap — never use in production) |

Capabilities that require capabilityConfig to be useful:

| Capability | Config key | Effect when absent | |---|---|---| | fs:read | sentinel.server.defaults.capabilityConfig["fs:read"].allowedPaths | All reads blocked | | fs:write | sentinel.server.defaults.capabilityConfig["fs:write"].allowedPaths | All writes blocked | | process:exec | sentinel.server.defaults.capabilityConfig["process:exec"].allowedCommands | All exec calls blocked | | events:subscribe | sentinel.server.defaults.capabilityConfig["events:subscribe"].allowedEvents | All event subscriptions blocked | | events:emit | sentinel.server.defaults.capabilityConfig["events:emit"].allowedEvents | All event emissions blocked | | events:unsubscribe | sentinel.server.defaults.capabilityConfig["events:unsubscribe"].allowedEvents | All event unsubscriptions blocked | | http:admin | sentinel.server.defaults.capabilityConfig["http:admin"].allowedPaths | All admin paths allowed (no path restriction) | | http:admin:global | sentinel.server.defaults.capabilityConfig["http:admin:global"].allowedPaths | All admin paths allowed (no path restriction) | | http:node | sentinel.server.defaults.capabilityConfig["http:node"].allowedPaths | All node paths allowed (no path restriction) | | http:node:global | sentinel.server.defaults.capabilityConfig["http:node:global"].allowedPaths | All node paths allowed (no path restriction) | | network:http | sentinel.server.defaults.capabilityConfig["network:http"].allowedUrls | All URLs allowed (guard inactive) | | network:tcp | sentinel.server.defaults.capabilityConfig["network:tcp"].allowedHosts | All hosts allowed (guard inactive) | | network:udp | sentinel.server.defaults.capabilityConfig["network:udp"].allowedHosts | All destinations allowed (guard inactive) | | network:ws | sentinel.server.defaults.capabilityConfig["network:ws"].allowedUrls | All WebSocket URLs allowed (guard inactive) | | native:addon | sentinel.server.defaults.capabilityConfig["native:addon"].allowedFiles | All addon loads blocked | | native:wasm | sentinel.server.defaults.capabilityConfig["native:wasm"].allowedModules | All WASM compilation blocked |

Adding a grant

Grants live in the sentinel.server.packages map inside settings.js. Each key is an npm package name exactly as it appears in node_modules/; the value is an object with a capabilities array and an optional capabilityConfig.

Node-RED core nodes do not need grants

Sentinel only applies capability checks to packages loaded from the Node-RED userDir ({userDir}/node_modules/ or {userDir}/nodes/). Node-RED's own built-in nodes (inject, debug, function, http request, etc.) are part of the Node-RED installation itself and live outside the userDir, so Sentinel never gates them. You only need to add grants for third-party packages that users install into their userDir.

registry:write — required for every node package

Every node package must be granted registry:write so Sentinel allows it to call RED.nodes.registerType() at startup. Without this grant, Sentinel blocks the call, the node type is never registered, and Node-RED logs "Waiting for missing types" indefinitely.

// settings.js — minimal grant for a node package that needs no other privileges
module.exports = {
    sentinel: {
        server: {
            defaults: {
                capabilityConfig: {},
            },
            packages: {
                "my-custom-node": {
                    capabilities: ["registry:write"],
                    capabilityConfig: {},
                },
            },
            types: {},
        },
    },
};

Common grants

// settings.js
module.exports = {
    sentinel: {
        server: {
            defaults: {
                capabilityConfig: {},
            },
            packages: {
                // A node that reads its own credentials (this.credentials) directly.
                // See "Credential access patterns" below for config-node and cross-node cases.
                "node-red-contrib-influxdb": {
                    capabilities: ["registry:write", "node:credentials:read"],
                    capabilityConfig: {},
                },

                // A flow-auditing plugin that needs to inspect the runtime topology.
                "node-red-contrib-flow-auditor": {
                    capabilities: [
                        "registry:write",
                        "node:list", // RED.nodes.eachNode()
                        "node:wires:read", // read node.wires (output topology)
                        "flows:read", // RED.runtime.flows.getFlows() / getFlow(id)
                    ],
                    capabilityConfig: {},
                },

                // A tracing / APM plugin that hooks the message pipeline.
                // hooks:on-send fires before routing; hooks:post-deliver fires after delivery.
                "node-red-contrib-tracer": {
                    capabilities: ["registry:write", "hooks:on-send", "hooks:post-deliver"],
                    capabilityConfig: {},
                },

                // A node that registers its own admin UI routes.
                // http:admin covers httpAdmin; http:node covers httpNode.
                "node-red-contrib-dashboard": {
                    capabilities: ["registry:write", "http:admin", "http:node"],
                    capabilityConfig: {},
                },

                // A node that genuinely needs to run OS commands.
                "node-red-contrib-exec": {
                    capabilities: ["registry:write", "process:exec"],
                    capabilityConfig: {},
                },

                // A node that reads files from disk (e.g. a CSV reader).
                "node-red-contrib-file-in": {
                    capabilities: ["registry:write", "fs:read"],
                    capabilityConfig: {},
                },

                // A node that makes outbound HTTP calls.
                // network:http covers http.request/https.request.
                // Add specific URLs to sentinel.client.network.allowlist to restrict further.
                "node-red-contrib-http-request": {
                    capabilities: ["registry:write", "network:http"],
                    capabilityConfig: {},
                },

                // A plugin (no node types) that listens to runtime events.
                // Plugins are registered via the node-red.plugins key in package.json
                // and do not call registerType — no registry:write needed.
                "node-red-contrib-audit-logger": {
                    capabilities: ["events:subscribe"],
                    capabilityConfig: {},
                },
            },
            types: {},
        },
    },
};

Sentinel identifies the calling package at runtime by walking the call stack and extracting the node_modules/<package> segment from the nearest frame that does not belong to Node-RED or Sentinel itself. The match is against the npm package name exactly as it appears on disk.

Credential access patterns

Sentinel blocks all .credentials access by default — whether a node reads its own secrets or those of another node. The grant is explicit so the operator consciously approves it and it shows up in the config as an audit signal.

Default: credentials are blocked

Without a grant, .credentials is undefined even for a node reading its own fields:

// node-red-contrib-my-api/index.js
module.exports = function (RED) {
    function MyApiNode(config) {
        RED.nodes.createNode(this, config);
        var apiKey = this.credentials.apiKey; // → undefined without the grant
    }
    RED.nodes.registerType("my-api", MyApiNode);
};

The same applies when reading .credentials from another node obtained via RED.nodes.getNode() — the accessing package needs the grant. Credential access via package grant is scoped: the package can only read credentials from node types it registered. To read credentials from a node type registered by a different package, a types entry on the target type is required (see below).

The config-node pattern (preferred — no cap needed)

Most packages only need their own config node's secret. The safe way to handle this is for the config node to copy the secret into a plain property during construction. Sentinel only proxies nodes returned by getNode(), not a node's own this, so this path is unguarded and requires no capability grant:

// node-red-contrib-influxdb/index.js
module.exports = function (RED) {
    function InfluxConfigNode(config) {
        RED.nodes.createNode(this, config);
        this.token = this.credentials.token; // own this — not proxied, no cap needed
        this.host  = config.host;
    }
    RED.nodes.registerType("influxdb-config", InfluxConfigNode);

    function InfluxWriteNode(config) {
        RED.nodes.createNode(this, config);
        var configNode = RED.nodes.getNode(config.configId);
        this.on("input", function (msg) {
            writeToInflux(configNode.host, configNode.token, msg.payload); // plain property — no cap
            this.send(msg);
        });
    }
    RED.nodes.registerType("influxdb-write", InfluxWriteNode);
};
// settings.js — node:credentials:read not needed under this pattern
sentinel: {
    server: {
        defaults: { capabilityConfig: {} },
        packages: {
            "node-red-contrib-influxdb": {
                capabilities: ["registry:write"],
                capabilityConfig: {},
            },
        },
        types: {},
    },
}

Grant via settings.sentinel.server.packages (scoped to own node types)

node:credentials:read grants a package access to .credentials only on node types it registered. It cannot read credentials from nodes registered by other packages — that requires an explicit types entry on the target type (described below).

// settings.js
sentinel: {
    server: {
        defaults: { capabilityConfig: {} },
        packages: {
            "node-red-contrib-my-api": {
                capabilities: ["registry:write", "node:credentials:read"],
                capabilityConfig: {},
            },
        },
        types: {},
    },
}

With this grant, node-red-contrib-my-api can read .credentials on any node whose type it registered. Calling getNode(someOtherPkgNodeId).credentials returns undefined even with this grant — the other package's node type is not in the allowlist.

Grant via .sentinel-grants.json

Sentinel also reads .sentinel-grants.json from userDir. This is the file the Sentinel editor panel writes, letting operators manage grants through the browser without touching settings.js (which is typically mounted :ro in production).

{
    "server": {
        "defaults": { "capabilityConfig": {} },
        "packages": {
            "node-red-contrib-my-api": {
                "capabilities": ["registry:write", "node:credentials:read"],
                "capabilityConfig": {}
            }
        },
        "types": {
            "influxdb-config": {
                "node:credentials:read": {
                    "packages": ["node-red-contrib-influxdb"],
                    "types": []
                }
            }
        }
    }
}
  • packages — equivalent to settings.sentinel.server.packages; merged at runtime (union). For credential access, scoped to the caller's own registered node types.
  • types — target-side allowlist keyed on the node type being accessed. Each capability entry has a packages list (caller npm packages) and a types list (caller node types). This is the only way to grant cross-package credential access — a package grant alone is not sufficient when the target type belongs to a different package.
types examples

Grant a specific package access to a config node's credentials:

{
    "server": {
        "types": {
            "mqtt-broker": {
                "node:credentials:read": {
                    "packages": ["node-red-contrib-mqtt-in", "node-red-contrib-mqtt-out"],
                    "types": []
                }
            }
        }
    }
}

Restrict wire rewiring to a specific tool package:

{
    "server": {
        "types": {
            "function": {
                "node:wires:write": { "packages": ["node-red-contrib-flow-manager"], "types": [] },
                "node:wires:read":  { "packages": ["node-red-contrib-flow-manager", "node-red-contrib-flow-auditor"], "types": [] }
            }
        }
    }
}

Grants are per package, not per node type

A single npm package can register many node types, but all of them share the same package name in the call stack. Sentinel cannot distinguish my-package/nodes/foo.js from my-package/nodes/bar.js at the frame level — both resolve to my-package. This is intentional: the package is the unit you install, audit, and sign off on.

Fine-grained control with scoped child packages

If you need different capability levels for different node types, publish each trust boundary as its own scoped package and group them under a parent that users install as a single dependency.

Parent package — a dependency aggregator with no node code of its own:

{
    "name": "@my-company/nodes",
    "version": "1.0.0",
    "dependencies": {
        "@my-company/node-data-formatter": "^1.0.0",
        "@my-company/node-mqtt-enricher": "^1.0.0",
        "@my-company/node-flow-auditor": "^1.0.0"
    }
}

Child packages — each has its own node-red field and npm identity:

{
    "name": "@my-company/node-mqtt-enricher",
    "version": "1.0.0",
    "node-red": { "nodes": { "mqtt-enricher": "index.js" } }
}

When a user runs npm install @my-company/nodes, npm (v7+) hoists the children to the top-level node_modules/. Node-RED discovers them directly because each has its own node-red field. Sentinel sees each child's package name independently, so grants can be applied at exactly the right granularity:

sentinel: {
    server: {
        defaults: { capabilityConfig: {} },
        packages: {
            // formatter needs no privileged access — registry:write is enough
            "@my-company/node-data-formatter": {
                capabilities: ["registry:write"],
                capabilityConfig: {},
            },
            // enricher reads credentials from a config node
            "@my-company/node-mqtt-enricher": {
                capabilities: ["registry:write", "node:credentials:read"],
                capabilityConfig: {},
            },
            // auditor needs to walk the full node graph
            "@my-company/node-flow-auditor": {
                capabilities: ["registry:write", "node:list", "node:wires:read"],
                capabilityConfig: {},
            },
        },
        types: {},
    },
}

This pattern is already established in the Node-RED ecosystem — @node-red/nodes, @node-red/runtime, and @node-red/editor-api are all separate packages under the @node-red namespace.

Service nodes as capability brokers

Sentinel resolves capabilities by looking at the nearest user-installed package in the call stack. This means a "service" package that wraps privileged operations acts as a capability broker: only the service needs the grant, not the packages that call into it.

How it works: When package A calls a method in package B, and package B internally makes a privileged call (e.g. fs.readFileSync), the call stack looks like this:

fs.readFileSync          ← built-in (skipped)
node-red-contrib-file-service/index.js:55   ← nearest userDir frame → checked
node-red-contrib-my-processor/index.js:12   ← outer frame (not checked for this call)

Sentinel finds node-red-contrib-file-service first and checks its grants. node-red-contrib-my-processor is not involved in the capability check at all.

In practice: publish a service package that wraps privileged operations behind a controlled API, grant it the capabilities it needs, and let consumer packages call it freely:

// node-red-contrib-file-service/index.js
// This package holds fs:read — consumers don't need it.
module.exports = function (RED) {
    function FileServiceNode(config) {
        RED.nodes.createNode(this, config);
        // Exposed API — consumers call node.readConfig(), not fs directly.
        this.readConfig = function (filePath) {
            return require("fs").readFileSync(filePath, "utf8");
        };
    }
    RED.nodes.registerType("file-service", FileServiceNode);
};
// node-red-contrib-my-processor/index.js
// No fs capability needed — reads files through the service node.
module.exports = function (RED) {
    function ProcessorNode(config) {
        RED.nodes.createNode(this, config);
        var service = RED.nodes.getNode(config.serviceId);
        this.on("input", function (msg) {
            var data = service.readConfig("/data/config.json"); // service makes the fs call
            // ... process data
            this.send(msg);
        });
    }
    RED.nodes.registerType("my-processor", ProcessorNode);
};
// settings.js
sentinel: {
    server: {
        defaults: { capabilityConfig: {} },
        packages: {
            // Only the service needs fs:read — it owns the privileged boundary.
            "node-red-contrib-file-service": {
                capabilities: ["registry:write", "fs:read"],
                capabilityConfig: {},
            },
            // The consumer needs no capability beyond registering its node type.
            "node-red-contrib-my-processor": {
                capabilities: ["registry:write"],
                capabilityConfig: {},
            },
        },
        types: {},
    },
}

This pattern is useful when multiple consumer packages need the same privileged operation: centralise it in one well-audited service package, grant only that package the capability, and consumers remain unprivileged. The service becomes the policy enforcement point — it decides what it exposes, and Sentinel enforces that nothing bypasses it.

Defense architecture

Sentinel runs inside the same Node.js process as every package it protects against — there is no sandbox, no separate process, and no OS-level isolation. Meaningful enforcement in that environment requires layered hardening techniques:

| Layer | Technique | What it closes | |---|---|---| | 0 — Prototype hardening | Object.preventExtensions on all built-in prototypes | Prototype pollution before any third-party code runs | | 1 — Module interception | Module._load hook + non-configurable lock | require() of fs, http, child_process, vm, worker_threads | | 2 — Node isolation | ES6 Proxy on every getNode() return value | Property reads, writes, and defineProperty on live node instances | | 3 — Surface hardening | Guarded Express routing, process.env Proxy, router-stack Proxy | Post-init manipulation of the HTTP server and environment | | 4 — Network policy | Outbound HTTP/HTTPS/socket allowlist | Exfiltration paths not covered by the module gate | | Cross-cutting | Intrinsic capture, call-stack introspection, file integrity watchdog | Prototype mutation of guard helpers, call-identity forgery, on-disk tampering |

All built-in methods used by guard logic are pinned as standalone bound functions before the first require(), so a package that overwrites String.prototype.includes cannot blind the stack-frame checks. The Module._load hook is locked configurable: false immediately after installation so it cannot be stripped. Every node proxy intercepts defineProperty in addition to get/set, closing the bypass that would otherwise let a caller install a getter on a proxied node.

For the full reference — every technique explained with code examples and attack scenarios — see docs/defense-techniques.md.

Module access gates

Sentinel intercepts require() for dangerous built-in modules and blocks specific methods within them. When a call is blocked, Sentinel prints a warning to the Node-RED console and tells you exactly which grant to add.

The warning format is:

[@allanoricil/nrg-sentinel] BLOCKED fs.readFileSync() — my-custom-node lacks fs:read
  Call stack:
    at Object.<anonymous> (/data/node_modules/my-custom-node/index.js:42:5)
  To allow, add to settings.js:
    sentinel: { server: { packages: { "my-custom-node": { capabilities: ["fs:read"], capabilityConfig: {} } } } }

For modules that are blocked entirely at require() time (like vm and worker_threads), the operation throws immediately:

[@allanoricil/nrg-sentinel] BLOCKED require('vm') — my-custom-node lacks vm:execute

File system — fs:read and fs:write

Triggered by require('fs'), require('fs/promises'), require('node:fs'), require('node:fs/promises').

| What you call | Cap needed | | -------------------------------------------------------------------------------------------- | ---------- | | readFile, readFileSync, readdir, createReadStream, stat, exists, watch | fs:read | | writeFile, writeFileSync, appendFile, createWriteStream, unlink, mkdir, rename | fs:write |

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED fs.readFileSync() — my-node lacks fs:read

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "fs:read"],
                capabilityConfig: {},
            },
        },
    },
}

Least-privilege default: granting fs:read or fs:write without configuring allowedPaths blocks every path. The grant alone is not sufficient — you must explicitly list which paths are accessible.

Path allowlist — capabilityConfig["fs:read"].allowedPaths / capabilityConfig["fs:write"].allowedPaths

Restrict the package to a specific set of paths. Each entry is either a directory (all files under it are permitted) or a glob pattern (any path matching the pattern is permitted).

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "fs:read": {
                    allowedPaths: [
                        "/data/config",          // all files under /data/config/
                        "/tmp/sentinel-*.json",  // glob: JSON files in /tmp starting with sentinel-
                        "/etc/app/**/*.conf",    // glob: any .conf under /etc/app/ at any depth
                    ],
                },
                "fs:write": {
                    allowedPaths: [
                        "/tmp",                  // writes anywhere under /tmp/
                        "/data/output/*.csv",    // glob: CSV files directly in /data/output/
                    ],
                },
            },
        },
        packages: {
            "my-node": {
                capabilities: ["registry:write", "fs:read", "fs:write"],
                capabilityConfig: {},
            },
        },
    },
}

Glob rules:

  • * matches any characters within a single path segment (does not cross /)
  • ** matches any number of path segments (crosses /)
  • ? matches a single character (not /)
  • [abc] / [!abc] — character classes with optional negation

fs:write additionally has an unconditional hard block on node_modules — no package can write to node_modules regardless of allowedPaths.

Per-package path restrictions

The global sentinel.server.defaults.capabilityConfig in settings.js applies the same allowedPaths to every package that holds the capability. For stronger isolation — where different packages need access to different path subtrees — use per-package capabilityConfig via the admin panel (#/packages → select a package → capability config section).

Per-package config is stored in .sentinel-grants.json inside each package's capabilityConfig entry and overrides the global sentinel.server.defaults.capabilityConfig for that specific package:

// .sentinel-grants.json (written by the admin panel)
{
  "server": {
    "defaults": { "capabilityConfig": {} },
    "packages": {
      "pkg-a": { "capabilities": ["fs:read"], "capabilityConfig": { "fs:read": { "allowedPaths": ["/data/pkg-a/**"] } } },
      "pkg-b": { "capabilities": ["fs:read"], "capabilityConfig": { "fs:read": { "allowedPaths": ["/data/pkg-b/**"] } } }
    }
  }
}

With this config, pkg-a can only read from /data/pkg-a/ and pkg-b can only read from /data/pkg-b/, regardless of any global allowedPaths. Resolution order: per-package config → global defaults → blocked.

The same per-package scoping is available for fs:write, process:exec, native:addon, and native:wasm.

Outbound HTTP — network:http

Triggered when a node calls http.request(), https.request(), http.get(), or the global fetch() (Node.js built-in fetch via undici). Both http.request() and fetch() are covered by a single network:http capability — there is no separate network:fetch.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED http.request() — my-node lacks network:http
// NRG Sentinel: network:http not granted — my-node  (also for fetch())

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:http"],
                capabilityConfig: {},
            },
        },
    },
}

You can restrict which URLs are reachable using sentinel.server.defaults.capabilityConfig["network:http"].allowedUrls. This is the global default — it applies to every package holding network:http unless they have their own per-package entry that adds to it:

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "network:http": {
                    allowedUrls: [
                        "https://api.example.com/",
                        "https://metrics.internal/",
                    ],
                },
            },
        },
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:http"],
                capabilityConfig: {},
            },
        },
    },
}

A package can add its own extra URLs on top of the global default via its capabilityConfig:

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "network:http": { allowedUrls: ["https://shared.example.com/"] },
            },
        },
        packages: {
            "pkg-a": {
                capabilities: ["registry:write", "network:http"],
                capabilityConfig: {
                    "network:http": { allowedUrls: ["https://pkg-a-only.internal/"] },
                },
            },
        },
    },
}

The effective allowlist for pkg-a is the union of the global default and its own entry: ["https://shared.example.com/", "https://pkg-a-only.internal/"]. Packages with no per-package entry see only the global list.

If allowedUrls is not configured at either level the URL guard is inactive and the package may reach any host (subject to the capability gate).

sentinel.client.network.allowlist is a separate, browser-side policy fed to the Service Worker. It restricts which URLs the Node-RED editor UI itself may fetch — it does not affect server-side Node.js package HTTP calls.

Outbound TCP — network:tcp

Triggered by net.createConnection(), net.connect(), and tls.connect() / tls.createConnection(). A package with only network:http cannot open raw TCP sockets. Use network:tcp for least privilege when you only need TCP (not UDP).

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED net.createConnection() — my-node lacks network:tcp

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:tcp"],
                capabilityConfig: {
                    "network:tcp": {
                        allowedHosts: ["api.example.com:443", "*.internal:*"],
                    },
                },
            },
        },
    },
}

UDP sockets — network:udp

Triggered by dgram.createSocket(). The allowedHosts list (format: host:port) is enforced at socket.send() time, blocking sends to non-listed destinations even after the socket is created.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED dgram.createSocket() — my-node lacks network:udp

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:udp"],
                capabilityConfig: {
                    "network:udp": {
                        allowedHosts: ["syslog.example.com:514"],
                    },
                },
            },
        },
    },
}

WebSocket connections — network:ws

Triggered by HTTP requests containing an Upgrade: websocket header. The WebSocket protocol is built on HTTP transport — the guard detects the upgrade header inside the existing http.request() / https.request() guard and requires network:ws instead of network:http.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED http.request() — my-node lacks network:ws

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:ws"],
                capabilityConfig: {
                    "network:ws": {
                        allowedUrls: ["wss://realtime.example.com/**", "ws://localhost:8080"],
                    },
                },
            },
        },
    },
}

network:ws and network:listen — when do you need both?

network:ws grants the ability to use the WebSocket protocol. Whether you also need network:listen depends on how you create the WebSocket server:

  • WebSocket client (outbound connection) — only network:ws needed
  • WS server attached to Node-RED's existing HTTP server — only network:ws needed (no new port is bound)
  • Standalone WS server on its own port — needs BOTH network:ws AND network:listen

Binding a port — network:listen

Triggered by net.createServer(), http.createServer(), or https.createServer() binding a new TCP port. This is intentionally separate from outbound capabilities — starting a server exposes your Node-RED instance to inbound traffic on a new port.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED net.createServer() — my-node lacks network:listen

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:listen"],
                capabilityConfig: {},
            },
        },
    },
}

You do not need network:listen if you are only making outbound connections (those use network:http, network:tcp, etc.).

DNS lookups — network:dns

Triggered by require('dns').lookup(), resolve(), and all other dns methods, including dns/promises variants. DNS is a known data-exfiltration channel (subdomains can encode data to an attacker-controlled nameserver).

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED dns.lookup() — my-node lacks network:dns

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "network:dns"],
                capabilityConfig: {},
            },
        },
    },
}

Child processes — process:exec

Triggered by child_process.exec(), execSync(), spawn(), spawnSync(), execFile(), fork().

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED child_process.execSync() — process:exec not granted for my-node

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "process:exec"],
                capabilityConfig: {},
            },
        },
    },
}

Command allowlist — capabilityConfig["process:exec"].allowedCommands

Even when process:exec is granted, you can restrict which commands the package may run to a pre-approved set identified by their SHA-256 hash of the exact command string evaluated at call time.

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "process:exec": {
                    // Only commands whose SHA-256 string hash appears here may run.
                    // Compute with: printf '%s' 'whoami' | sha256sum | cut -d' ' -f1
                    allowedCommands: [
                        "abc123...",  // sha256 of "whoami"
                        "def456...",  // sha256 of "git status"
                    ],
                },
            },
        },
        packages: {
            "my-node": {
                capabilities: ["registry:write", "process:exec"],
                capabilityConfig: {},
            },
        },
    },
}

When allowedCommands is configured:

  • The exact evaluated command string (e.g. "whoami", "git status") is hashed with SHA-256 and compared against the set. Any change to the command — different arguments, injected metacharacters — produces a different hash and is blocked.
  • A command not in the set is blocked with: command hash not in allowedCommands
  • Node-RED's own built-in nodes (e.g. the native exec node) are internal callers and are exempt — only external user-space packages with process:exec are subject to the hash check.
  • Omitting allowedCommands (from both sentinel.server.defaults.capabilityConfig and per-package capabilityConfig) blocks all commands — the least-privilege default requires an explicit allowlist even when process:exec is granted.

To compute hashes:

# Linux / macOS
printf '%s' 'whoami' | sha256sum | cut -d' ' -f1
printf '%s' 'git status' | sha256sum | cut -d' ' -f1

Environment variables — process:env:read

Triggered when a node reads process.env.SOME_KEY. This gates reads from the global process.env object.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED process.env.DATABASE_URL — my-node lacks process:env:read

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "process:env:read"],
                capabilityConfig: {},
            },
        },
    },
}

VM contexts — vm:execute

The entire require('vm') call is blocked if the caller lacks this capability. Code run inside a vm context bypasses all Module._load hooks — Sentinel cannot see what it does.

// Node-RED log when blocked (throws, does not just warn):
// [@allanoricil/nrg-sentinel] BLOCKED require('vm') — my-node lacks vm:execute

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "vm:execute"],
                capabilityConfig: {},
            },
        },
    },
}

Worker threads — threads:spawn

The entire require('worker_threads') call is blocked. Workers run in a separate V8 isolate whose module loader is invisible to Sentinel.

// Node-RED log when blocked (throws, does not just warn):
// [@allanoricil/nrg-sentinel] BLOCKED require('worker_threads') — my-node lacks threads:spawn

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "threads:spawn"],
                capabilityConfig: {},
            },
        },
    },
}

Native addons — native:addon

Blocked at two layers: Module._extensions['.node'] (covers all require() paths) and process.dlopen() (covers direct low-level loads). Native addons run C++ code in-process and can bypass every JavaScript-level guard once loaded.

// Node-RED log when blocked (throws):
// [@allanoricil/nrg-sentinel] BLOCKED require('addon.node') — native:addon not granted for my-node

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "native:addon"],
                capabilityConfig: {},
            },
        },
    },
}

File allowlist — capabilityConfig["native:addon"].allowedFiles

Even when native:addon is granted, the addon's SHA-256 content hash must appear in allowedFiles. The hash check is mandatory — omitting allowedFiles blocks all native addon loads even when the capability is granted.

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "native:addon": {
                    // Only addons whose SHA-256 content hash appears here may load.
                    // Compute with: sha256sum /path/to/addon.node | cut -d' ' -f1
                    allowedFiles: [
                        "abc123...",  // sha256 of the approved .node file
                    ],
                },
            },
        },
        packages: {
            "my-node": {
                capabilities: ["registry:write", "native:addon"],
                capabilityConfig: {},
            },
        },
    },
}

Node-RED's own built-in native addons (bcrypt, bufferutil, etc.) are internal callers and are always exempt.

WebAssembly — native:wasm

Guards WebAssembly.compile(), WebAssembly.instantiate(), new WebAssembly.Module(), and the streaming variants. WASM executes arbitrary code outside Module._load and can exfiltrate data via any granted network capabilities.

// Node-RED log when blocked:
// [@allanoricil/nrg-sentinel] BLOCKED WebAssembly.compile() — native:wasm not granted for my-node

sentinel: {
    server: {
        packages: {
            "my-node": {
                capabilities: ["registry:write", "native:wasm"],
                capabilityConfig: {},
            },
        },
    },
}

Module hash allowlist — capabilityConfig["native:wasm"].allowedModules

Even when native:wasm is granted, the WASM bytes must hash to a value in allowedModules. The hash check is mandatory — omitting allowedModules blocks all WASM compilation even when the capability is granted.

sentinel: {
    server: {
        defaults: {
            capabilityConfig: {
                "native:wasm": {
                    // SHA-256 hash of the raw WASM bytes (not the .wasm file name).
                    // Compute with: node -e "var c=require('crypto'),b=require('fs').readFileSync('module.wasm');console.log(c.createHash('sha256').update(b).digest('hex'))"
                    allowedModules: [
                        "def456...",  // sha256 of the approved WASM module bytes
                    ],
                },
            },
        },
        packages: {
            "my-node": {
                capabilities: ["registry:write", "native:wasm"],
                capabilityConfig: {},
            },
        },
    },
}

Streaming APIs (compileStreaming / instantiateStreaming): bytes arrive from a network response and cannot be hashed at call time. These APIs are allowed when allowedModules is configured but are not hash-checked. To block streaming WASM entirely, do not grant native:wasm.

Module cache — automatic (no grant required)

require.cache writes are guarded automatically. No configuration is needed. Sentinel blocks:

  • Cache poisoning — replacing another package's or Node-RED's cache entry.
  • Cache eviction — deleting a cache entry owned by a different package.
  • Fake-entry injection — pre-populating a path outside the attacker's own package directory.

Legitimate operations (a package writing entries within its own package directory, reading any entry) are always permitted.

ESM import() and function-level guards

Node.js ESM import() expressions bypass Module._load — they go through the ESM loader. Sentinel's response is to patch the module objects themselves at preload time, before any package code runs, rather than relying solely on intercepting the load call.

fs, net, dns, vm, worker_threads, http, https, dgram, and tls are all patched at the function level on their cached module objects during Sentinel's initialisation. An import('fs') expression returns the same cached fs object with already-patched methods — the guards fire identically to require('fs').

The installer bundler (rolldown) is used for code splitting and singleton semantics — it bundles each package's Node-RED entry points into ESM chunks with shared chunk extraction, so modules like datastores and event buses initialise exactly once across entries. This is not an ESM security measure; the function-level patches are what make the guards effective regardless of how a module is obtained.

Built-in Node-RED nodes and the trust boundary

Sentinel's capability gates — process:exec, fs:read, network:http, and every other grant — apply exclusively to third-party npm packages installed via the palette manager. They do not apply to Node-RED's own built-in nodes (exec, function, http request, file in/out, tcp, etc.).

This is intentional. Built-in nodes are installed by root into a read-only directory (/usr/src/nodered/node_modules/@node-red/). They cannot be modified at runtime by any package Sentinel guards against, and they are always visible in the flow graph — any use of them is fully auditable by the operator.

The questions below address common concerns about this boundary.


"Wait — I can drop an exec node into a flow and run arbitrary commands. Doesn't that mean Sentinel isn't working?"

To inject an exec node into a live deployment, an attacker must successfully deploy a flow change. Sentinel wraps every deployment in an approval queue. The review UI that approves or rejects deployments is served at a separate URL (/nrg/plugins/sentinel/deployments/review) in its own browser tab — completely outside the Node-RED editor's JavaScript context.

A rogue palette node cannot tamper with that approval page from the client side: it runs in a different document with its own isolated JavaScript environment, and the Node-RED editor has no cross-origin access to it. On the server side, the approval API requires both admin authentication and the X-Sentinel-Admin: 1 header. Sentinel's tamper detection in the editor (plugin.html) locks fetch and XMLHttpRequest against injected headers, so a rogue plugin in the editor context cannot forge that header.

An attacker who has already obtained legitimate admin credentials can deploy exec nodes — but they also have direct terminal access, file system access, and the ability to edit settings.js. At that point Sentinel is not the relevant defence; credential and access management is.


"A function node can read files and make HTTP requests. Is that a gap?"

Partially, but it is constrained on multiple levels.

First, deploying a flow that contains a malicious function node faces the same deployment queue barrier described above.

Second, function nodes cannot call require('fs') or require('http') unless functionExternalModules: true is explicitly enabled in settings.js and the specific module is listed in the node's setup. That option is off by default.

Third, when functionExternalModules is enabled, function node code runs in an anonymous eval context. Sentinel's stack-frame analyser treats anonymous frames as unattributed external calls — the same anonymous-frame detection that blocks injection through new Function() wrappers. The call is blocked under the label unknown with no capability grant. This means a function node attempting fs.readFileSync() or http.request() through the guarded modules will be denied, even without a recognised package name.


"What about the HTTP request node — can't it exfiltrate data and bypass network:http?"

The HTTP request node is a built-in and is treated as an internal caller, so the network:http capability gate does not fire for it. The relevant control layer here is Node-RED's own authentication and access model: only operators with deploy rights can add an HTTP request node to a flow, and only flows that pass the Sentinel deployment queue are activated.

If your threat model requires restricting what HTTP requests the HTTP request node can make — for example in a multi-tenant environment where different operators manage different flows — the network policy allowlist (sentinel.client.network.allowlist in settings.js) applies globally to all outbound requests at the process level and is not limited to third-party packages. The service worker layer enforces the same allowlist for browser-originated requests.


"What if I write a custom node that uses the exec node — does it inherit the exec node's trust?"

No. A custom node cannot import the exec node as a library — Node-RED built-in nodes are not callable utilities, they are flow graph entities. The only way a custom node can leverage the exec node is by sending a message to an already-deployed instance via RED.nodes.getNode(id).receive(msg).

That path is blocked at multiple points:

  • The exec node must already be deployed. For it to exist in the running flow it had to pass through Sentinel's deployment approval queue. A rogue package cannot add nodes to the flow at runtime — flow mutations go through Node-RED's storage layer, which is protected.
  • The custom node must know the exec node's ID. IDs are not predictable. Enumerating all nodes to find an exec instance requires the node:list capability grant, which the package must hold explicitly.
  • The direct approach is already blocked. If the custom node simply calls child_process.spawn() or child_process.exec() itself, Sentinel's process:exec guard fires immediately — the custom node is an external caller in {userDir}/node_modules/ and the stack-frame check identifies it. No indirection through a built-in node is needed for that check to apply.

In short: calling through an exec node requires the exec node to already be trusted and deployed by an authenticated operator. At that point the exec node is part of the authorised flow, not an exploit path.


"So Sentinel only protects against palette nodes?"

That is the primary threat model: a developer installs a palette node from npm that looks legitimate but contains hidden malicious code. Without Sentinel, that code runs automatically the moment Node-RED loads the node, with full access to credentials, the file system, the network, and the ability to exec arbitrary processes — none of it visible in any flow graph.

Core nodes are part of the operator's explicit flow design. They are chosen deliberately, they are visible, and they can only be activated through a deployment that the Sentinel approval queue has cleared. That is a fundamentally different attack surface from a supply-chain compromise hidden inside an npm package dependency.

Local / Host install

Install Sentinel into your Node-RED user directory:

cd ~/.node-red
npm install @allanoricil/nrg-sentinel

Node-RED auto-discovers plugins in ~/.node-red/node_modules/, so the Sentinel sidebar and plugin features load automatically on the next restart. No extra configuration is needed for that.

To activate the preload guard (module-level interception), set NODE_OPTIONS before starting Node-RED:

NODE_OPTIONS="--require @allanoricil/nrg-sentinel/preload" node-red

To make this permanent, add it to your startup script, systemd unit, or shell profile:

# ~/.bashrc or ~/.zshrc
export NODE_OPTIONS="--require @allanoricil/nrg-sentinel/preload"

Why not ./node_modules/.bin/node-red? The node-red package itself is not installed inside ~/.node-red — it lives in the global node_modules. The Sentinel wrapper binary handles both cases automatically: when node-red is co-installed in the same node_modules tree (Docker) it resolves the entrypoint directly; otherwise it finds node-red via PATH. Either way, the preload is injected via NODE_OPTIONS.

Docker

The Dockerfile produces a hardened production image. The security model rests on three layers.

Filesystem layout

/usr/src/nodered   owned by root, chmod a-w   Node-RED + Sentinel install
/etc/nodered       owned by root, chmod a-w   settings.js (read-only config)
/data              owned by nodered           flows, credentials, c