openclaw - ✅(Solved) Fix [Bug]: device.token.revoke skips caller-scope containment check, allowing any PAIRING_SCOPE client to revoke tokens for any device [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#71990Fetched 2026-04-27 05:36:24
View on GitHub
Comments
1
Participants
2
Timeline
3
Reactions
0
Author
Participants
Timeline (top)
closed ×1commented ×1cross-referenced ×1

Error Message

Add the same resolveMissingRequestedScope caller-scope containment check to device.token.revoke that is already present in device.token.rotate at devices.ts:374. After the deniesCrossDeviceManagement check, load the target device's current scopes (via the paired device record), then verify that the caller's scopes (authz.callerScopes) cover all scopes held by the target role. Deny the request with an opaque error if the check fails. This mirrors the rotate handler's existing pattern and ensures a PAIRING_SCOPE-only caller cannot revoke tokens for roles carrying scopes they do not hold.

Root Cause

Technical Reproduction

  1. Authenticate to the gateway as a primary-auth caller (password or shared credential) with only operator.pairing scope assigned.
  2. Call device.pair.list (gated on operator.pairing) to enumerate all paired devices and their roles.
  3. Call device.token.revoke with { deviceId: <target-device-id>, role: <target-role> } for a device whose token carries operator.admin or operator.write scope.
  4. Observe: the revoke succeeds (ok: true). The deniesCrossDeviceManagement check passes because a primary-auth caller has callerDeviceId: null.
  5. The target device's token is now revoked; it will be refused on any subsequent re-auth attempt.

Fix Action

Fixed

PR fix notes

PR #71991: fix(gateway): add scope containment check to device.token.revoke

Description (problem / solution / changelog)

Summary

  • Problem: device.token.revoke skips the resolveMissingRequestedScope caller-scope containment check that device.token.rotate enforces. A caller with only operator.pairing scope can revoke tokens for devices carrying higher-scope roles like operator.admin or operator.write.
  • Why it matters: Enables targeted denial of service against higher-privileged paired devices by invalidating their access credentials.
  • What changed: Added resolveMissingRequestedScope scope containment check to the device.token.revoke handler, mirroring the existing pattern in device.token.rotate. The unknown-device path now hard-denies rather than falling through to the mutation (TOCTOU close).
  • What did NOT change: The device-ownership check (deniesCrossDeviceManagement) added in #50626 remains unchanged. No changes to device.token.rotate, revokeDeviceToken, or any other handler.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #71990
  • Related #50626
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: The #50626 fix added device-ownership checks (resolveDeviceManagementAuthz + deniesCrossDeviceManagement) to both device.token.rotate and device.token.revoke, but only added the scope containment check (resolveMissingRequestedScope) to rotate. The revoke handler was left with ownership-only authorization. Additionally, the scope check was conditionally gated on if (pairedDevice), leaving a TOCTOU window where a device absent at getPairedDevice time but present when revokeDeviceToken acquires its lock would bypass the scope guard entirely.
  • Missing detection / guardrail: No test verified that device.token.revoke enforced scope containment comparable to device.token.rotate.
  • Contributing context: The two handlers share similar structure but were secured incrementally across separate fixes, and the scope check was not applied consistently.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/gateway/server-methods/devices.test.ts (5 new tests), src/gateway/server.device-token-rotate-authz.test.ts (1 new test)
  • Scenario the test should lock in: A caller with only operator.pairing scope cannot revoke a device token whose role carries operator.admin scope. An admin-scoped caller can still revoke. A caller with matching scopes can revoke. A missing device is hard-denied rather than falling through.
  • Why this is the smallest reliable guardrail: The unit tests mock getPairedDevice and resolveMissingRequestedScope to isolate the authorization logic. The integration test exercises the full gateway WebSocket RPC path with real device pairing state.
  • Existing test that already covers this: The rotate handler has analogous tests in server.device-token-rotate-authz.test.ts but no parallel existed for revoke.

User-visible / Behavior Changes

None. The change only adds deny paths for previously-allowed operations. Callers revoking within their scope set see no difference.

Diagram (if applicable)

Before:
[caller with pairing scope] -> device.token.revoke(deviceId, admin-role) -> ok: true (token revoked)

After:
[caller with pairing scope] -> device.token.revoke(deviceId, admin-role) -> denied (scope containment)
[caller with admin scope]   -> device.token.revoke(deviceId, admin-role) -> ok: true (token revoked)
[unknown device]             -> device.token.revoke(deviceId, role)         -> denied (unknown-device)

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? Yes — revoke now enforces scope containment before invalidating tokens
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? Yes — narrows who can revoke device tokens to callers holding the target role's scopes
  • If any Yes, explain risk + mitigation: The change restricts a previously-permissive path. The only risk is that callers relying on the unintended ability to revoke higher-scope tokens will be denied. This is the intended fix.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22+ (pnpm)
  • Model/provider: N/A (code-level fix)
  • Integration/channel: WebSocket JSON-RPC gateway

Steps

  1. Authenticate to gateway as primary-auth caller with only operator.pairing scope
  2. Call device.pair.list to enumerate paired devices
  3. Call device.token.revoke targeting a device with operator.admin-scoped token
  4. Observe: revoke denied with opaque error (before fix: succeeds)

Expected

Revoke denied — caller does not hold operator.admin scope.

Actual

Before fix: revoke succeeds (ok: true), target device token revoked. After fix: revoke denied (ok: false), target device token unchanged.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: Unit tests (25/25 passed), integration tests (7/7 passed), build + typecheck + lint clean
  • Edge cases checked: Caller with matching scopes (allowed), admin caller (allowed), unknown device (hard-deny), device-level scope fallback
  • What I did NOT verify: Full E2E gateway deployment with live devices; Android/iOS native client behavior

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes — for callers already within their scope set. No for callers relying on the ability to revoke higher-scope tokens (which was unintended behavior).
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: Existing automation or tooling that calls device.token.revoke with a low-scope caller for a high-scope target will start receiving deny responses.
    • Mitigation: This is the intended security fix. Such callers should use an admin-scoped token instead.

Changed files

  • src/gateway/server-methods/devices.test.ts (modified, +170/-1)
  • src/gateway/server-methods/devices.ts (modified, +36/-1)
  • src/gateway/server.device-token-rotate-authz.test.ts (modified, +41/-0)

Code Example

"device.token.revoke": async ({ params, respond, context, client }) => {
    // ...params validation...
    const { deviceId, role } = params as { deviceId: string; role: string };
    const authz = resolveDeviceManagementAuthz(client, deviceId);
    if (deniesCrossDeviceManagement(authz)) {
      // ...deny cross-device management for device-token callers...
      return;
    }
    // NOTE: no resolveMissingRequestedScope check here
    const entry = await revokeDeviceToken({ deviceId, role });
    // ...respond...
},

---

const authz = resolveDeviceManagementAuthz(client, deviceId);
if (deniesCrossDeviceManagement(authz)) { /* deny */ return; }
const requestedScopes = normalizeDeviceAuthScopes(
  scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
);
const missingScope = resolveMissingRequestedScope({
  role,
  requestedScopes,
  allowedScopes: authz.callerScopes,
});
if (missingScope) {
  // ...deny...
}
RAW_BUFFERClick to expand / collapse

Severity Assessment

CVSS Assessment

Metricv3.1v4.0
Score8.1 / 10.07.2 / 10.0
SeverityHighHigh
VectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HCVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N
CalculatorCVSS v3.1 CalculatorCVSS v4.0 Calculator

Threat Model Alignment

Classification: security-specific

The device token architecture isolates each paired device's capability to its individually approved scope set. device.token.rotate enforces this via resolveMissingRequestedScope (devices.ts:374), but device.token.revoke (devices.ts:428) skips the same containment check. A primary-authenticated caller with only operator.pairing scope passes the device-ownership gate (callerDeviceId is null, so deniesCrossDeviceManagement returns false) and can revoke tokens for any device regardless of the target role's scope elevation relative to the caller. This crosses the device-scope trust boundary that SECURITY.md's operator trust model relies on for intra-gateway credential isolation.

Impact

A WebSocket client authenticated with only operator.pairing scope can revoke device tokens for any paired device and any role, including devices whose tokens carry operator.admin or operator.write scope. This enables a lower-privileged client to perform a targeted denial of service against other paired devices by invalidating their access credentials.

Affected Component

File: src/gateway/server-methods/devices.ts:428-475

"device.token.revoke": async ({ params, respond, context, client }) => {
    // ...params validation...
    const { deviceId, role } = params as { deviceId: string; role: string };
    const authz = resolveDeviceManagementAuthz(client, deviceId);
    if (deniesCrossDeviceManagement(authz)) {
      // ...deny cross-device management for device-token callers...
      return;
    }
    // NOTE: no resolveMissingRequestedScope check here
    const entry = await revokeDeviceToken({ deviceId, role });
    // ...respond...
},

Contrast with device.token.rotate at line 374 which enforces both ownership and scope containment:

const authz = resolveDeviceManagementAuthz(client, deviceId);
if (deniesCrossDeviceManagement(authz)) { /* deny */ return; }
const requestedScopes = normalizeDeviceAuthScopes(
  scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
);
const missingScope = resolveMissingRequestedScope({
  role,
  requestedScopes,
  allowedScopes: authz.callerScopes,
});
if (missingScope) {
  // ...deny...
}

Technical Reproduction

  1. Authenticate to the gateway as a primary-auth caller (password or shared credential) with only operator.pairing scope assigned.
  2. Call device.pair.list (gated on operator.pairing) to enumerate all paired devices and their roles.
  3. Call device.token.revoke with { deviceId: <target-device-id>, role: <target-role> } for a device whose token carries operator.admin or operator.write scope.
  4. Observe: the revoke succeeds (ok: true). The deniesCrossDeviceManagement check passes because a primary-auth caller has callerDeviceId: null.
  5. The target device's token is now revoked; it will be refused on any subsequent re-auth attempt.

Note: Cross-device revocation by a device-token caller was partially mitigated by the ownership check added in the fix for issue #50626. However, a primary-authenticated caller with operator.pairing scope is still not subject to scope containment, and a device-token caller managing its own device can revoke higher-scope roles without scope containment verification.

Demonstrated Impact

device.token.revoke is gated by operator.pairing scope in method-scopes.ts (line 65). The resolveDeviceManagementAuthz check (added post-#50626) blocks cross-device management by device-token callers but does not enforce scope containment. The revokeDeviceToken function applies the revoke unconditionally given a valid deviceId and role — no comparison of the caller's scopes against the target device token's role scopes is performed.

The sibling method device.token.rotate explicitly calls resolveMissingRequestedScope (devices.ts:374) to verify the caller holds every scope the target role uses before allowing rotation. This containment check is absent in device.token.revoke. A primary-auth caller with only operator.pairing scope can revoke tokens for any device at any role level, effectively disconnecting higher-privilege devices from the gateway.

Environment

Verified against release v2026.4.24 (commit 6507387f433deb0e7beb22abb4625a40f3b6b97e, published 2026-04-25T18:15:17Z) and current upstream main (commit dc9ce2a1bf1cd5b6c3eae1ef8a18be6d71078b81). Affected code: src/gateway/server-methods/devices.ts lines 428-475 and src/infra/device-pairing.ts.

Remediation Advice

Add the same resolveMissingRequestedScope caller-scope containment check to device.token.revoke that is already present in device.token.rotate at devices.ts:374. After the deniesCrossDeviceManagement check, load the target device's current scopes (via the paired device record), then verify that the caller's scopes (authz.callerScopes) cover all scopes held by the target role. Deny the request with an opaque error if the check fails. This mirrors the rotate handler's existing pattern and ensures a PAIRING_SCOPE-only caller cannot revoke tokens for roles carrying scopes they do not hold.

extent analysis

TL;DR

The most likely fix is to add a resolveMissingRequestedScope check to the device.token.revoke method to ensure the caller's scopes cover all scopes held by the target role.

Guidance

  • Add the resolveMissingRequestedScope check after the deniesCrossDeviceManagement check in the device.token.revoke method.
  • Load the target device's current scopes via the paired device record.
  • Verify that the caller's scopes (authz.callerScopes) cover all scopes held by the target role.
  • Deny the request with an opaque error if the check fails.

Example

"device.token.revoke": async ({ params, respond, context, client }) => {
    // ...params validation...
    const { deviceId, role } = params as { deviceId: string; role: string };
    const authz = resolveDeviceManagementAuthz(client, deviceId);
    if (deniesCrossDeviceManagement(authz)) {
      // ...deny cross-device management for device-token callers...
      return;
    }
    const requestedScopes = normalizeDeviceAuthScopes(
      pairedDevice.tokens?.[role]?.scopes ?? pairedDevice.scopes,
    );
    const missingScope = resolveMissingRequestedScope({
      role,
      requestedScopes,
      allowedScopes: authz.callerScopes,
    });
    if (missingScope) {
      // ...deny...
    }
    const entry = await revokeDeviceToken({ deviceId, role });
    // ...respond...
},

Notes

This fix mirrors the existing pattern in the device.token.rotate method and ensures that a PAIRING_SCOPE-only caller cannot revoke tokens for roles carrying scopes they do not hold.

Recommendation

Apply the workaround by adding the resolveMissingRequestedScope check to the device.token.revoke method, as this will prevent a lower-privileged client from revoking tokens for higher-

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING