CVE Patch Review

GHSA-XRQ9-JM7V-G9H7: Device-token pairing authorization scoped to caller device

GHSA-XRQ9-JM7V-G9H7 · Updated 2026-05-08 Root-cause

Summary

The patch addresses an authorization flaw in OpenClaw device pairing by distinguishing device-token sessions from broader operator/shared-secret sessions and enforcing per-device ownership checks on pairing list and approval/rejection flows. The change appears to correct the trust boundary that previously treated possession of the operator.pairing scope as globally sufficient.

Analysis

Vulnerability

GHSA-XRQ9-JM7V-G9H7 describes incorrect authorization in OpenClaw's device pairing RPCs. Before the fix, pairing operations were authorized primarily by scope, so any session carrying operator.pairing could enumerate pending/paired devices and approve or reject requests outside the caller's own device context. The advisory summary and changelog both state that non-admin paired-device sessions were not restricted to their own pairing objects, enabling cross-device visibility and actionability.

The diff shows the root of the issue: the vulnerable implementation did not consistently model whether the caller was a device-token-authenticated session versus a broader operator/shared-secret session. In src/gateway/server-methods/devices.ts, device.pair.list returned the full list, and approval logic passed only callerScopes into approveDevicePairing(...) without first verifying that the pending request belonged to the calling device. That is an authorization design error: scope alone was treated as sufficient authority for object-level operations.

// vulnerable pattern from devices.ts summary
"device.pair.list": async ({ params, respond }) => {
  respond(true, {
    pending: list.pending,
    paired: list.paired.map((device) => redactPairedDevice(device)),
  });
}

const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const approved = await approveDevicePairing(requestId, { callerScopes });

Sources: commit 5a12f30441d5b0b151f550daa2c5c9e8db61e2e6, GitHub Advisory GHSA-XRQ9-JM7V-G9H7, CVE report mirror.

Patch

The patch introduces an explicit session-authorization model and uses it to scope pairing operations. The key structural change is the addition of DeviceSessionAuthz and resolveDeviceSessionAuthz(client), which derive two important facts from the connection: whether the caller is an admin and whether the caller is a device-token-authenticated device session with a concrete callerDeviceId. The patched code only assigns callerDeviceId when client.isDeviceTokenAuth is true, which preserves broader visibility for non-device operator sessions and shared-auth sessions.

For device.pair.list, the handler now filters both pending and paired results to the caller's own device when the session is a non-admin device-token session. For approve/reject flows, the handler first loads the pending request via getPendingDevicePairing(requestId) and denies the action if the request does not exist or if its deviceId does not match the caller's device identity. The changelog explicitly states that admin and shared-secret operator sessions retain full visibility.

// patched pattern from devices.ts summary
function resolveDeviceSessionAuthz(client) {
  // derives isAdminCaller and callerDeviceId only for device-token auth
}

"device.pair.list": async ({ respond, client }) => {
  const authz = resolveDeviceSessionAuthz(client);
  const visibleList =
    authz.callerDeviceId && !authz.isAdminCaller
      ? {
          pending: list.pending.filter((request) => request.deviceId.trim() === authz.callerDeviceId),
          paired: list.paired.filter((device) => device.deviceId.trim() === authz.callerDeviceId),
        }
      : list;
}

if (authz.callerDeviceId && !authz.isAdminCaller) {
  const pending = await getPendingDevicePairing(requestId);
  if (!pending || pending.deviceId.trim() !== authz.callerDeviceId) {
    respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "device pairing approval denied"));
    return;
  }
}

The tests in src/gateway/server-methods/devices.test.ts materially strengthen confidence in the fix. They now distinguish isDeviceTokenAuth from plain scope-bearing sessions and cover at least these cases: filtered list results for non-admin device-token sessions, full list retention for admin device-token sessions, and full list retention for non-device operator sessions. The test additions align with the intended trust boundary described in the changelog.

Sources: commit 5a12f30441d5b0b151f550daa2c5c9e8db61e2e6, GitHub Advisory GHSA-XRQ9-JM7V-G9H7.

Review

Pros

  • The patch addresses the authorization flaw at the correct abstraction layer by separating session type from raw scope membership. That is stronger than adding ad hoc checks in individual handlers.
  • Object-level authorization is now enforced for both read and state-changing operations: list visibility is filtered, and approve/reject require ownership of the pending request for non-admin device-token callers.
  • The use of client.isDeviceTokenAuth avoids over-constraining legitimate non-device operator/shared-secret sessions that may carry a device identity but should retain broader operational access, matching the changelog statement.
  • Tests were expanded to cover the critical matrix of caller types, which reduces regression risk around the nuanced distinction between device-token and non-device sessions.

Cons

  • The provided diff excerpt shows explicit checks for list and approval, and references rejection support in tests/changelog, but the full rejection-path implementation is not visible in the snippet. Reviewers should confirm parity in device.pair.reject in the full commit.
  • The denial path returns INVALID_REQUEST with messages like device pairing approval denied. From a protocol perspective, that may be intentional, but it conflates authorization failure with request validity and could complicate client-side handling or telemetry classification.
  • The filtering logic relies on trim()-normalized string equality for device IDs. That is consistent with the existing code style shown, but long term it would be preferable to centralize canonical device ID normalization to avoid subtle mismatches across handlers.

Verdict

Root-cause.

This patch appears to fix the underlying authorization mistake rather than merely masking one code path. The vulnerable design granted global pairing authority based on operator.pairing scope alone; the patch corrects that by introducing session-aware authorization and enforcing per-device ownership checks for non-admin device-token callers. Based on the commit summary, changelog, and tests, the remediation is technically sound and appropriately scoped to the trust boundary described in the patch commit and the advisory. The main follow-up is verification that the reject path and any adjacent pairing RPCs implement the same ownership model consistently.

Sources