openclaw - ✅(Solved) Fix [Bug]: Device pairing scope escalation via repeated re-request before operator approval [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#72006Fetched 2026-04-27 05:36:11
View on GitHub
Comments
1
Participants
2
Timeline
3
Reactions
0
Author
Participants
Timeline (top)
closed ×1commented ×1cross-referenced ×1

Root Cause

Root cause: mergePendingDevicePairingRequest (device-pairing.ts:175) calls mergeScopes(existing.scopes, incoming.scopes) — a pure additive set union — to combine the pending record's scopes with incoming request scopes. This function is called from requestDevicePairing (line 276) whenever a device re-submits while a pending entry exists, and the merged result overwrites the stored pending record.

Fix Action

Fixed

PR fix notes

PR #72007: fix(infra): preserve pending scopes on device re-request

Description (problem / solution / changelog)

Summary

  • Problem: mergePendingDevicePairingRequest (in requestDevicePairing) uses mergeScopes to combine incoming scopes with existing pending scopes on re-request. A pre-approval device can silently expand its pending scope set before the operator clicks Approve, causing the expanded scopes to be stamped onto the approved token.
  • Why it matters: Allows a device to obtain operator-level permissions (e.g., operator.admin) that the operator never intended to grant, by re-submitting a pairing request with elevated scopes during the pending window.
  • What changed: In buildReplacement within requestDevicePairing, re-request now preserves the original pending scopes instead of merging with incoming scopes. The first scope set seen by the operator is the one that will be approved.
  • What did NOT change: Roles merge (mergeRoles) remains unchanged on re-request.

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 #72006 (to be filled after issue creation)
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: mergeScopes in buildReplacement (within requestDevicePairing) is an additive set union. When a device re-submits with elevated scopes, the merged result overwrites the pending record. approveDevicePairing then reads pending.scopes directly without re-validating against the original request — so the operator approves the expanded scope set.
  • Missing detection / guardrail: No test verified that re-request does NOT expand the pending scope set.
  • Contributing context: The additive merge behavior was intentional for role updates but was incorrectly applied to scopes.

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/infra/device-pairing.test.ts
  • Scenario the test should lock in: A device that submits a pending request with scopes A, then re-submits with scopes A+B, should result in the operator seeing scope A (not A+B) on approval. The re-submitted scopes should not expand the pending set.
  • Why this is the smallest reliable guardrail: Tests directly exercise requestDevicePairing with multiple submissions and verify the final pending state.
  • Existing test that already covers this: Test "supersedes pending requests when requested roles/scopes change" was asserting the buggy (merged) behavior — updated to assert the correct (preserved) behavior.

User-visible / Behavior Changes

None for normal operation. Devices cannot silently expand their pending scope set between submission and operator approval.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? Yes — narrows the scope set a device can obtain via re-submission
  • If any Yes, explain risk + mitigation: Restricts an unintended exploit path. No impact on legitimate approved scopes.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22+ (pnpm)
  • Model/provider: N/A (code-level fix)
  • Integration/channel: Device pairing protocol

Steps

  1. Device submits requestDevicePairing({ scopes: ["operator.read"], role: "operator" }) — pending record created with scopes: ["operator.read"]
  2. Before operator approves, device submits requestDevicePairing({ scopes: ["operator.read", "operator.admin"], role: "operator" })
  3. Operator opens approval UI and clicks Approve
  4. Observe: token issued with scopes from the original request, not the expanded set

Expected

Token scopes match the original pending request scopes, not the re-submitted expanded set.

Actual

Before fix: token issued with merged (expanded) scopes. After fix: token issued with preserved original scopes.

Evidence

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

Human Verification (required)

  • Verified scenarios: 38/38 unit tests passed including the updated scope-preservation test
  • Edge cases checked: Re-request preserves original scopes, approval uses preserved scopes, roles merge still works
  • What I did NOT verify: Full E2E pairing flow with live gateway; UI approval display

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 devices re-requesting within the same scope set
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • None. The change removes an unintended exploit path; legitimate re-requests with no scope change work identically.

Changed files

  • docs/.generated/plugin-sdk-api-baseline.sha256 (modified, +2/-2)
  • src/infra/device-pairing.test.ts (modified, +33/-4)
  • src/infra/device-pairing.ts (modified, +1/-5)

Code Example

function mergePendingDevicePairingRequest(
  existing: DevicePairingPendingRequest,
  incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
  isRepair: boolean,
): DevicePairingPendingRequest {
  return {
    ...existing,
    displayName: incoming.displayName ?? existing.displayName,
    platform: incoming.platform ?? existing.platform,
    // ...
    roles: mergeRoles(existing.roles, existing.role, incoming.role),
    scopes: mergeScopes(existing.scopes, incoming.scopes), // line 175: additive union — escalation path
    // ...
  };
}

---

const existing = Object.values(state.pendingById).find(
  (pending) => pending.deviceId === deviceId,
);
if (existing) {
  const merged = mergePendingDevicePairingRequest(existing, req, isRepair);
  state.pendingById[existing.requestId] = merged; // overwrites pending record with escalated scopes
  await persistState(state, baseDir);
  return { status: "pending" as const, request: merged, created: false };
}

---

const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); // reads escalated scopes
const nextScopes =
  requestedScopes.length > 0
    ? requestedScopes                    // uses merged (attacker-expanded) scope set
    : normalizeDeviceAuthScopes(
        existingToken?.scopes ??
          approvedScopes ??
          existing?.approvedScopes ??
          existing?.scopes,
      );
RAW_BUFFERClick to expand / collapse

Severity Assessment

CVSS Assessment

Metricv3.1v4.0
Score7.9 / 10.08.4 / 10.0
SeverityHighHigh
VectorCVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:NCVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N
CalculatorCVSS v3.1 CalculatorCVSS v4.0 Calculator

Threat Model Alignment

Classification: security-specific

This finding targets the device pairing approval trust boundary: the gate between a pending (untrusted) device and an operator-approved (trusted) device with operator-level remote capability. SECURITY.md states "Pairing a node grants operator-level remote capability on that node" — the pairing approval is the documented mechanism for establishing that trust. The exploit allows a pre-approval device to silently expand the scopes stamped onto its approved token by re-submitting a pairing request with elevated scopes before the operator clicks Approve, causing the backend state to diverge from what the approval UI displayed. This is not covered by any Out of Scope clause: it is not a multi-tenant isolation claim, not a trusted-plugin finding, and not a prompt-injection chain. It requires a concrete bypass of the operator's intended scope grant at the trust-establishment boundary.

Impact

A device in pending (not yet operator-approved) state can silently escalate the scopes stamped onto its approved token by re-submitting a pairing request with elevated scopes before the operator clicks Approve. The additive scope union in mergePendingDevicePairingRequest (device-pairing.ts:175) combined with approveDevicePairing reading scopes directly from the modified pending record (device-pairing.ts:326-329) allows a pre-approval device to obtain broader operator-level permissions than the operator intended to grant.

Affected Component

File: openclaw/src/infra/device-pairing.ts:159-182

function mergePendingDevicePairingRequest(
  existing: DevicePairingPendingRequest,
  incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
  isRepair: boolean,
): DevicePairingPendingRequest {
  return {
    ...existing,
    displayName: incoming.displayName ?? existing.displayName,
    platform: incoming.platform ?? existing.platform,
    // ...
    roles: mergeRoles(existing.roles, existing.role, incoming.role),
    scopes: mergeScopes(existing.scopes, incoming.scopes), // line 175: additive union — escalation path
    // ...
  };
}

File: openclaw/src/infra/device-pairing.ts:272-279

const existing = Object.values(state.pendingById).find(
  (pending) => pending.deviceId === deviceId,
);
if (existing) {
  const merged = mergePendingDevicePairingRequest(existing, req, isRepair);
  state.pendingById[existing.requestId] = merged; // overwrites pending record with escalated scopes
  await persistState(state, baseDir);
  return { status: "pending" as const, request: merged, created: false };
}

File: openclaw/src/infra/device-pairing.ts:326-335

const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); // reads escalated scopes
const nextScopes =
  requestedScopes.length > 0
    ? requestedScopes                    // uses merged (attacker-expanded) scope set
    : normalizeDeviceAuthScopes(
        existingToken?.scopes ??
          approvedScopes ??
          existing?.approvedScopes ??
          existing?.scopes,
      );

Technical Reproduction

  1. Device A calls requestDevicePairing({ deviceId: "X", scopes: ["operator.read"], role: "operator", ... }). A pending record is created with scopes: ["operator.read"].
  2. Before the operator acts, Device A calls requestDevicePairing({ deviceId: "X", scopes: ["operator.admin"], role: "operator", ... }) again. mergePendingDevicePairingRequest at line 175 merges scopes to ["operator.read", "operator.admin"] and overwrites the pending record at line 277.
  3. Operator opens the approval UI and sees the original display (which may still show operator.read if the UI was loaded before step 2). Operator clicks "Approve".
  4. approveDevicePairing(requestId) reads pending.scopes = ["operator.read", "operator.admin"] at line 326 and issues a token with nextScopes = ["operator.read", "operator.admin"] after normalizeDeviceAuthScopes.
  5. Device A authenticates to the gateway using the token with operator.admin scope accepted by verifyDeviceToken.
  6. The operator.admin scope is persisted via persistState and survives gateway restarts.

The behavior is explicitly confirmed by the test at device-pairing.test.ts:118-148 ("merges pending roles/scopes for the same device before approval"), which asserts that a second request with elevated scopes results in the merged scope set on approval.

Demonstrated Impact

Root cause: mergePendingDevicePairingRequest (device-pairing.ts:175) calls mergeScopes(existing.scopes, incoming.scopes) — a pure additive set union — to combine the pending record's scopes with incoming request scopes. This function is called from requestDevicePairing (line 276) whenever a device re-submits while a pending entry exists, and the merged result overwrites the stored pending record.

When the operator later calls approveDevicePairing, line 326 derives the token scopes as normalizeDeviceAuthScopes(pending.scopes) — directly from the now-expanded pending record. There is no check comparing the current pending scopes against the scopes shown to the operator at the time they opened the approval UI.

What breaks: The operator's approval UI fetches the pending list at some point in time, showing the original (limited) scopes. The device, by calling requestDevicePairing again with elevated scopes before the operator acts, causes the backend state to be silently updated. When the operator clicks "Approve", the token is issued with the expanded scope set. The exploit window is the entire PENDING_TTL_MS = 5 * 60 * 1000 period (device-pairing.ts:79).

Deterministic repro: No race condition is required. The device calls requestDevicePairing twice — once with limited scopes, once with elevated scopes — before the operator approves. The second call always overwrites (by union) the pending record. The scope escalation is deterministic.

Existing controls that do not prevent the exploit:

  • withLock (device-pairing.ts:81): prevents concurrent writes within a single operation, but does not prevent the device from calling requestDevicePairing sequentially. The escalation is sequential, not concurrent.
  • PENDING_TTL_MS = 5 * 60 * 1000 (device-pairing.ts:79): closes the pending window eventually, but the entire 5-minute window is available for the two-step escalation.
  • approveDevicePairing scope logic (device-pairing.ts:326-335): no validation comparing what was displayed to the operator against what is stored in pending.scopes at approval time. The function uses the current pending.scopes directly.

Environment

Standard project runtime. Exploitable whenever a device is in pending state and the operator has not yet acted on the approval request. No special configuration required beyond having a device initiate pairing. Confirmed against v2026.4.24 (commit 6507387f433deb0e7beb22abb4625a40f3b6b97e, published 2026-04-25T18:15:17Z) and current upstream main (commit e67093f333758b5cffd27b28badcff953b53758f). Affected code: src/infra/device-pairing.tsmergePendingDevicePairingRequest (scope additive merge) and approveDevicePairing (reads directly from pending.scopes).

Remediation Advice

When a device re-submits a pairing request and a pending entry already exists, do not expand the scope set. Two viable approaches:

  • Preserve the original scope set: On re-request, update only non-security fields (e.g., displayName, platform, remoteIp, silent) and retain scopes from the first pending request, ignoring incoming scope escalation attempts.
  • Reset and re-notify: If scope changes must be allowed on re-request, delete the existing pending record and create a new one, ensuring the operator sees a fresh approval prompt with the new scopes — no silent merge.

The invariant to enforce: the scopes stamped onto the token by approveDevicePairing must never exceed the scopes that were shown to the operator at the time of their most recent approval action.

extent analysis

TL;DR

To fix the issue, update the mergePendingDevicePairingRequest function to preserve the original scope set when a device re-submits a pairing request, preventing silent scope escalation.

Guidance

  • Identify the mergePendingDevicePairingRequest function in device-pairing.ts and modify it to retain the original scopes from the first pending request, ignoring incoming scope escalation attempts.
  • Consider implementing a reset and re-notify approach, deleting the existing pending record and creating a new one when scope changes are detected, to ensure the operator sees a fresh approval prompt with the new scopes.
  • Review the approveDevicePairing function to ensure it validates the scopes stamped onto the token against what was displayed to the operator, preventing scope escalation.
  • Test the updated code to verify that the scope set is not expanded when a device re-submits a pairing request with elevated scopes.

Example

function mergePendingDevicePairingRequest(
  existing: DevicePairingPendingRequest,
  incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
  isRepair: boolean,
): DevicePairingPendingRequest {
  return {
    ...existing,
    displayName: incoming.displayName ?? existing.displayName,
    platform: incoming.platform ?? existing.platform,
    // ...
    roles: mergeRoles(existing.roles, existing.role, incoming.role),
    scopes: existing.scopes, // preserve original scopes
    // ...
  };
}

Notes

The provided solution focuses on preserving the original scope set to prevent silent scope escalation. However, the reset and re-notify approach may be more suitable depending on the specific requirements of the system.

Recommendation

Apply the workaround by updating the mergePendingDevicePairingRequest function to preserve the original scope set, as this approach directly addresses the root cause of the issue and prevents scope escalation.

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

openclaw - ✅(Solved) Fix [Bug]: Device pairing scope escalation via repeated re-request before operator approval [1 pull requests, 1 comments, 2 participants]