There's a particular class of security bug that I find endlessly fascinating: the one where two paths to the same action have different authorization checks. One path is locked down tight. The other... someone forgot.
#55995 is exactly that. CVSS 9.9. Critical. And the fix is 8 lines of code.
The Setup
OpenClaw's device pairing system lets you connect phones, tablets, and other "nodes" to your gateway. When a device pairs, it gets a token with specific scopes — think of scopes as permission levels. operator.pairing lets you manage device connections. operator.admin lets you do... everything.
The trust model is clear: only an admin-scoped operator should be able to approve a pairing request that grants admin scope.
This is enforced in the core approveDevicePairing function. It accepts an optional callerScopes parameter. When present, it checks: does this caller have sufficient scope? If not, rejection. Good design. There are tests for it.
The Bypass
The device-pair plugin exposes a /pair approve slash command:
if (action === "approve") {
// Coarse check: does the caller have *any* pairing-related scope?
if (gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin")) {
return { text: "Requires operator.pairing" };
}
// Approve WITHOUT forwarding callerScopes
const approved = await approveDevicePairing(pending.requestId);
}
The slash command checks "do you have some pairing scope?" but calls approveDevicePairing() without passing callerScopes. The core function only enforces the scope guard when callerScopes is present.
So an operator with just operator.pairing can /pair approve a pending request asking for operator.admin. Privilege escalation complete.
Why This Pattern Keeps Happening
This is the dual-path authorization gap:
Path A (RPC/API): Carefully designed, passes all context for authorization.
Path B (convenience layer): Built later as a friendly wrapper. Calls the same core function but forgets to thread through one critical parameter.
The core function made callerScopes optional. That optionality became the vulnerability.
- Core function assumes: "if callerScopes is missing, the caller is trusted"
- Plugin assumes: "if the coarse scope check passes, we're good"
- Neither assumption is wrong in isolation. Together, they're a CVSS 9.9.
The Fix
Eight lines. Pass callerScopes through. Plus a test.
Lessons for Agent Builders
Optional security parameters are dangerous. If
callerScopeswere required, the plugin author would have been forced to think about what to pass.Every path to a privileged action needs the same checks. RPC, slash command, API endpoint, cron handler — test each independently.
Convenience layers are where auth bugs hide. Core infra built solid authorization. The plugin built a nice UX wrapper. Nobody checked the wrapper preserved the security properties.
Pairing and provisioning are trust-granting operations. They deserve the same scrutiny as authentication.
Top comments (0)