Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

node-opcua RBAC Bypass: Anonymous Writes to OPC UA (CVE-2026-95860)

node-opcua ignores namespace default role permissions, so anonymous OPC UA clients can write variables and call methods; fixed in 2.180.0.

TL;DR

  • node-opcua, the Node.js and TypeScript OPC UA stack, does not apply a namespace's default role permissions to remote sessions. An anonymous client can write variables and call methods that rely on that default.
  • Versions 2.42.0 through 2.179.0 are affected. The fix is in 2.180.0, released on August 30, 2026; the advisory was published on October 23, 2026.
  • CVSS v3.1 9.1 Critical. The advisory includes proof-of-concept code, no exploitation in the wild is known, and the CVE is not in CISA KEV as of November 11, 2026.
  • Upgrade to 2.180.0 or later. Until then, set explicit per-node RolePermissions and disable anonymous sessions; see "How do I fix it?" for the limits.

Am I affected?

All of these must hold:

  • You run an OPC UA server built on node-opcua 2.42.0 through 2.179.0.
  • The server accepts anonymous sessions, or an attacker can obtain a low-privilege session.
  • A namespace default is declared, either with setDefaultRolePermissions() or by a loaded NodeSet2 file, and at least one variable or method relies on that default instead of explicit per-node permissions.

The advisory separates two cases. A server that declares no role permissions anywhere is permissive by design, and the advisory calls that an insecure default rather than a bypass. The 9.1 flaw is the second case: an operator declares a namespace default, the call succeeds, and the runtime silently ignores it.

Branch Affected versions Fixed version
2.x 2.42.0 to 2.179.0 2.180.0, released 2026-08-30
2.x before 2.42.0 Not affected per the advisory
1.x Not covered by the advisory Not assessed

Check the installed packages and the server configuration:

npm ls node-opcua node-opcua-server node-opcua-address-space
node -e "console.log(require('node-opcua/package.json').version)"
grep -R "setDefaultRolePermissions" --include='*.js' --include='*.ts' .
grep -R "allowAnonymous" --include='*.js' --include='*.ts' .

A call to setDefaultRolePermissions() in your code, or a NodeSet2 file with a DefaultRolePermissions value, means your server is in the second case if any node relies on it.

How do I fix it?

Upgrade every service that embeds node-opcua to 2.180.0 or later, then decide how the server treats sessions with no applicable policy.

  1. Install the fixed package: npm install node-opcua@2.180.0
  2. Update the server and address-space packages if your project pins them separately: npm install node-opcua-server@2.180.0 node-opcua-address-space@2.180.0
  3. Rebuild and redeploy each OPC UA server. The fix is in the runtime, so a lock-file update alone does not change a running process.
  4. Confirm the version: node -e "console.log(require('node-opcua/package.json').version)"
  5. Decide the policy for sessions that resolve to no role, or nodes with no policy at any level. The default stays permissive. Set unresolvedPermissionPolicy: "deny" on the server if you want fail-closed behaviour: javascript const server = new OPCUAServer({ unresolvedPermissionPolicy: "deny" });

If you cannot upgrade, these measures reduce exposure. They do not replace the fix.

  1. Set explicit per-node RolePermissions on every variable and method that matters. The advisory confirms that per-node permissions are enforced correctly, including on the vulnerable versions.
  2. Disable anonymous sessions with allowAnonymous: false, and require authenticated sessions for every client.
  3. For a programmatically created namespace, calling setNamespaceMetaData() after setDefaultRolePermissions() makes the old versions honour the default. It throws INamespace meta data already exists on NodeSet2-loaded namespaces, so that route is closed for those.
  4. Do not rely on setDefaultRolePermissions() alone on 2.179.0 or earlier. It records a policy that the enforcement path does not read.

What is the root cause?

OPC UA resolves access control in two steps. A node with explicit RolePermissions uses them. A node without them inherits the namespace's DefaultRolePermissions, as OPC UA Part 3 section 5.2.9 requires. Section 4.8.3 then requires a default-deny result: when the operation bits are not set, the server returns Bad_UserAccessDenied. node-opcua inverted that default and disconnected the two paths that should have agreed.

The first bug is the default itself. When no policy reached the check, the helper returned every permission:

function getPermissionForRole(
    rolePermissions: RolePermissionType[] | null,
    role: NodeId
): PermissionFlag {
    if (rolePermissions === null) {
        return allPermissions; // null means "grant everything" to ANY role, including Anonymous
    }

The second bug is why the policy arrived as null. Namespace.setDefaultRolePermissions() wrote the policy to a private field, and the local getter read that field. The enforcement path read the DefaultRolePermissions metadata node under Server/Namespaces instead:

const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
if (defaultRolePermissions) {
    const dataValue = defaultRolePermissions.readValue();
    if (dataValue?.statusCode.isGood()) {
        return dataValue.value.value as RolePermissionType[] | null;
    }
}

The two paths met only when the operator also called setNamespaceMetaData(), which binds the field to the node. That call throws on a namespace loaded from a NodeSet2 file. On top of that, ServerEngine.initialize() reset every namespace's DefaultRolePermissions property to a Null value on every start, dropping a default a nodeset had declared.

The result: an operator declares a namespace policy, the declaration succeeds, and the enforcement path resolves the policy to null. The null then grants every permission to every role, including Anonymous. The advisory's proof of concept measures the gap directly: after setDefaultRolePermissions([Operator]), the namespace getter returns the policy while getApplicableRolePermissions() returns null.

How is it exploited?

The attacker needs network access to the OPC UA endpoint and a session. An anonymous session is enough when the server allows one, which is common in test and integration setups. The advisory's end-to-end proof of concept shows the sequence against a server that declared an Operator-only namespace default.

  1. The server declares a namespace default with setDefaultRolePermissions(), or loads a NodeSet2 namespace that declares one, and leaves a variable or method without per-node permissions.
  2. The attacker connects to the endpoint and creates an anonymous session.
  3. The attacker writes the variable that relies on the namespace default, for example a process setpoint.
  4. The enforcement path resolves the default to null, returns allPermissions, and the write succeeds.
  5. The attacker calls a bound method in the same namespace, for example a shutdown or reset method. Methods have no separate access-level gate, and a bound method is executable by default.

The client side of the advisory's proof of concept is short:

const session = await client.createSession({ type: UserTokenType.Anonymous });
await session.write({
    nodeId: variableNodeId,
    attributeId: AttributeIds.Value,
    value: { value: { dataType: DataType.Double, value: 999.0 } }
});
await session.call({ objectId: parentNodeId, methodId: methodNodeId, inputArguments: [] });

The advisory also publishes a NodeSet2 variant where setNamespaceMetaData() throws, so no configuration activates the namespace default on the vulnerable version. That variant is the clearest demonstration that per-node permissions are the only reliable control before the upgrade.

How does the attack flow?

The configured policy is written to one place and read from another, so the enforcement path sees no policy and grants everything.

flowchart LR A["Operator declares a namespace default
with setDefaultRolePermissions"] --> B["Policy stored in a
namespace field"] B --> C["Enforcement reads the
DefaultRolePermissions node"] C --> D["Node is Null or absent,
so the policy resolves to null"] D --> E["getPermissionForRole(null)
returns allPermissions"] F["Anonymous OPC UA session"] --> E E --> G["Write to Variables and
Call bound Methods allowed"] G --> H["Setpoints changed,
methods executed"] style E fill:#f8d7da

What can an attacker do?

An anonymous attacker can write variables and call methods that the operator believed were protected by the namespace default. That includes process setpoints, safety parameters, and methods that shut down, reboot or reconfigure a device. The advisory also grants Read, so the attacker can inspect the values first.

The conditions are part of the impact:

  • The server must be reachable over the network, and it must accept an anonymous session or give the attacker a session with a role that the namespace default does not grant.
  • A namespace default must be declared, and the target node must rely on it. A node with explicit RolePermissions is enforced correctly and is not affected.
  • The deployment determines the availability impact. The advisory scores A:N because a method that affects availability is deployment-specific, and notes that a shutdown or role-administration method makes A:H reachable.

A server that declares no policy anywhere stays permissive and is outside this flaw's scope.

Why does the fix work?

The fix makes the two resolution paths agree. When the metadata node is absent or carries a Null value, the enforcement path now falls back to the namespace field that setDefaultRolePermissions() writes:

     const namespaces = namespace.addressSpace.rootFolder?.objects?.server?.namespaces;
-    if (!namespaces) {
-        return null;
-    }
-    const uaNamespaceObject = namespaces.getChildByName(namespace.namespaceUri);
-    if (!uaNamespaceObject) {
-        return null;
-    }
-    const defaultUserRolePermissions = uaNamespaceObject.getChildByName("DefaultUserRolePermissions") as UAVariable;
-    if (defaultUserRolePermissions) {
-        const dataValue = defaultUserRolePermissions.readValue();
-        if (dataValue?.statusCode.isGood() && dataValue.value.value && dataValue.value.value.length > 0) {
-            return dataValue.value.value as RolePermissionType[];
+    const uaNamespaceObject = namespaces?.getChildByName(namespace.namespaceUri);
+    if (uaNamespaceObject) {
+        // DefaultUserRolePermissions is the user-specific policy and wins when it carries a value.
+        const defaultUserRolePermissions = uaNamespaceObject.getChildByName("DefaultUserRolePermissions") as UAVariable;
+        if (defaultUserRolePermissions) {
+            const dataValue = defaultUserRolePermissions.readValue();
+            if (dataValue?.statusCode.isGood() && dataValue.value.value && dataValue.value.value.length > 0) {
+                return dataValue.value.value as RolePermissionType[];
+            }
         }
-    }
-    const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
-    if (defaultRolePermissions) {
-        const dataValue = defaultRolePermissions.readValue();
-        if (dataValue?.statusCode.isGood()) {
-            return dataValue.value.value as RolePermissionType[] | null;
+        const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
+        if (defaultRolePermissions) {
+            const dataValue = defaultRolePermissions.readValue();
+            const value = dataValue?.value?.value as RolePermissionType[] | null | undefined;
+            if (dataValue?.statusCode.isGood() && value !== null && value !== undefined) {
+                return value;
+            }
         }
     }
-    return null;
+
+    return namespace.getDefaultRolePermissions();
 }

The same commit stops the server from overwriting a declared default at startup. ServerEngine.initialize() now initializes only properties that carry no value, so a default declared by a NodeSet2 survives:

-                    for (const ns of namespacesNode.getComponents()) {
-                        const defaultUserRolePermissions = ns.getChildByName("DefaultUserRolePermissions") as UAVariable | null;
-                        if (defaultUserRolePermissions) {
-                            defaultUserRolePermissions.setValueFromSource({ dataType: DataType.Null });
-                        }
-                        const defaultRolePermissions = ns.getChildByName("DefaultRolePermissions") as UAVariable | null;
-                        if (defaultRolePermissions) {
-                            defaultRolePermissions.setValueFromSource({ dataType: DataType.Null });
-                        }
+                    const initializeIfUnset = (variable: UAVariable | null) => {
+                        if (!variable) {
+                            return;
+                        }
+                        const dataValue = variable.readValue();
+                        const value = dataValue?.value?.value;
+                        const hasValue = dataValue?.statusCode.isGood() && value !== null && value !== undefined;
+                        if (!hasValue) {
+                            variable.setValueFromSource({ dataType: DataType.Null });
+                        }
+                    };
+                    for (const ns of namespacesNode.getComponents()) {
+                        initializeIfUnset(ns.getChildByName("DefaultUserRolePermissions") as UAVariable | null);
+                        initializeIfUnset(ns.getChildByName("DefaultRolePermissions") as UAVariable | null);
                     }

The fix is complete for the declared-policy case: a configured default now reaches enforcement, and a per-node policy still overrides it. It does not change the first case. A server that declares no policy keeps the permissive resolution, because the release notes state that the default behaviour of a server that configures neither is unchanged. The new unresolvedPermissionPolicy option is the opt-in fail-closed control, and it defaults to "allow".

How do I check if I was compromised?

No indicators of compromise are published for this flaw. A successful write can be invisible in the value history if the attacker restores the old value, and many OPC UA servers keep no per-session audit trail. What you can check:

  1. Look for anonymous sessions in the server's logs or audit events, especially around changes to setpoints, modes or configuration.
  2. Review the values and timestamps of variables that rely on a namespace default. A change outside a known maintenance window is a lead.
  3. Check the server configuration for allowAnonymous: true and for a declared namespace default that you believed was enforced.
  4. If your server writes OPC UA audit events, search them for Anonymous and for Call or Write on sensitive nodes.

A clean log does not prove the server was not accessed. It means the server did not record the access.

Timeline

  • 2026-08-28: the fix is merged as PR #1580, commit 0261e873.
  • 2026-08-30: node-opcua 2.180.0 is released with the fix.
  • 2026-10-23: the GitHub advisory GHSA-xggf-m53x-jc3f is published.
  • 2026-11-03: CVE-2026-95860 is published by the GitHub CNA.

The fix shipped 54 days before the public advisory, leaving a long window for unpatched deployments
Figure 1: From the fix to the public advisory. Source: node-opcua release notes and GitHub advisory, checked November 11, 2026

FAQ

Does this affect every node-opcua server?

No. A server that declares no role permissions anywhere is in the advisory's first case, which it calls documented permissive behaviour rather than a bypass. A server that declares a namespace default and relies on it for some nodes is the affected case.

I never called setDefaultRolePermissions. Am I affected?

Possibly. A NodeSet2 file can declare DefaultRolePermissions, and the server reset that value at startup on the vulnerable versions. Check the loaded nodesets and whether any node relies on the namespace default.

Is anonymous access required?

No, but it is the simplest path. A low-privilege authenticated session also benefits when its role is not in the namespace default. Disabling anonymous sessions reduces the exposure without closing the flaw.

Was it exploited in the wild?

No exploitation is known as of November 11, 2026. The advisory includes proof-of-concept code, CISA's SSVC block records exploitation as poc, and the CVE is not in CISA KEV.

Does 2.180.0 make my server fail closed?

No. It makes a declared namespace default apply to remote sessions. A server with no policy stays permissive unless you set unresolvedPermissionPolicy: "deny", which the release documents as a constructor option.

Which packages do I update?

Update node-opcua to 2.180.0 or later. If your project pins node-opcua-server or node-opcua-address-space separately, update them to the same version so the runtime and the address space agree.

Find exposed OPC UA endpoints with Ostorlab

Use Ostorlab Attack Surface Discovery to inventory the internet-facing hosts and services you own, so you know which OPC UA endpoints are reachable and need the upgrade first. It maps exposed services; it does not detect this CVE.

References

  • GitHub advisory GHSA-xggf-m53x-jc3f, "node-opcua: Namespace-Level RBAC Fail-Open", published 2026-10-23, with the root-cause analysis and PoC-A, PoC-B and PoC-C: https://github.com/node-opcua/node-opcua/security/advisories/GHSA-xggf-m53x-jc3f (checked November 11, 2026)
  • The CVE-2026-95860 record, GitHub CNA, published 2026-11-03: https://www.cve.org/CVERecord?id=CVE-2026-95860 (checked November 11, 2026)
  • Fix PR #1580, "honour namespace default role permissions on the enforcement path", merged 2026-08-28: https://github.com/node-opcua/node-opcua/pull/1580 (checked November 11, 2026)
  • Fix commit 0261e873, 2026-08-28: https://github.com/node-opcua/node-opcua/commit/0261e873900800983c622e01120f484dc6063698 (checked November 11, 2026)
  • node-opcua 2.180.0 release notes, published 2026-08-30, including the behaviour changes and the unresolvedPermissionPolicy option: https://github.com/node-opcua/node-opcua/releases/tag/v2.180.0 (checked November 11, 2026)
  • The node-opcua package page, 93,781 weekly downloads in the week ending 2026-11-10: https://www.npmjs.com/package/node-opcua (checked November 11, 2026)
  • The node-opcua-server package page, 131,431 weekly downloads in the same week: https://www.npmjs.com/package/node-opcua-server (checked November 11, 2026)
  • OPC UA Part 3, section 5.2.9 on RolePermissions inheritance: https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9 (checked November 11, 2026)
  • OPC UA Part 3, section 4.8.3 on default-deny permission evaluation: https://reference.opcfoundation.org/Core/Part3/v105/docs/4.8.3 (checked November 11, 2026)
  • CISA's Known Exploited Vulnerabilities catalog, catalog version 2026.11.10, which does not list CVE-2026-95860: https://www.cisa.gov/known-exploited-vulnerabilities-catalog (checked November 11, 2026)
  • Figure 1, the fix-to-advisory timeline: dates from the fix commit, the 2.180.0 release and the advisory listed in References, checked November 11, 2026.