VYPR
Medium severity5.4NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41909

CVE-2026-41909

Description

OpenClaw before 2026.4.20 contains an improper authorization vulnerability in paired-device pairing management that allows limited-scope sessions to enumerate and act on pairing requests. Attackers with paired-device access can approve or operate on unrelated pending device requests within the same gateway scope.

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.20

Patches

1
5a12f30441d5

Limit paired-device pairing actions to the caller device (#69375)

https://github.com/openclaw/openclawAgustin RiveraApr 20, 2026via nvd-ref
7 files changed · +528 19
  • CHANGELOG.md+1 0 modified
    @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
     - Context engine/plugins: stop rejecting third-party context engines whose `info.id` differs from the registered plugin slot id. The strict-match contract added in 2026.4.14 broke `lossless-claw` and other plugins whose internal engine id does not equal the slot id they are registered under, producing repeated `info.id must match registered id` lane failures on every turn. Fixes #66601. (#66678) Thanks @GodsBoy.
     - Agents/compaction: rename embedded Pi compaction lifecycle events to `compaction_start` / `compaction_end` so OpenClaw stays aligned with `pi-coding-agent` 0.66.1 event naming. (#67713) Thanks @mpz4life.
     - Security/dotenv: block all `OPENCLAW_*` keys from untrusted workspace `.env` files so workspace-local env loading fails closed for new runtime-control variables instead of silently inheriting them. (#473)
    +- Gateway/device pairing: restrict non-admin paired-device sessions (device-token auth) to their own pairing list, approve, and reject actions so a paired device cannot enumerate other devices or approve/reject pairing requests authored by another device. Admin and shared-secret operator sessions retain full visibility. (#69375) Thanks @eleqtrizit.
     
     ## 2026.4.20
     
    
  • src/gateway/server.device-pair-approve-authz.test.ts+101 4 modified
    @@ -1,6 +1,10 @@
     import { describe, expect, test } from "vitest";
     import { WebSocket } from "ws";
    -import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
    +import {
    +  getPairedDevice,
    +  getPendingDevicePairing,
    +  requestDevicePairing,
    +} from "../infra/device-pairing.js";
     import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
     import {
       issueOperatorToken,
    @@ -26,13 +30,13 @@ describe("gateway device.pair.approve caller scope guard", () => {
           clientId: GATEWAY_CLIENT_NAMES.TEST,
           clientMode: GATEWAY_CLIENT_MODES.TEST,
         });
    -    const pending = loadDeviceIdentity("approve-target");
    +    const approverIdentity = loadDeviceIdentity("approve-attacker");
     
         let pairingWs: WebSocket | undefined;
         try {
           const request = await requestDevicePairing({
    -        deviceId: pending.identity.deviceId,
    -        publicKey: pending.publicKey,
    +        deviceId: approverIdentity.identity.deviceId,
    +        publicKey: approverIdentity.publicKey,
             role: "operator",
             scopes: ["operator.admin"],
             clientId: GATEWAY_CLIENT_NAMES.TEST,
    @@ -53,6 +57,53 @@ describe("gateway device.pair.approve caller scope guard", () => {
           expect(approve.ok).toBe(false);
           expect(approve.error?.message).toBe("missing scope: operator.admin");
     
    +      const paired = await getPairedDevice(approverIdentity.identity.deviceId);
    +      expect(paired).not.toBeNull();
    +      expect(paired?.approvedScopes).toEqual(["operator.admin"]);
    +    } finally {
    +      pairingWs?.close();
    +      started.ws.close();
    +      await started.server.close();
    +      started.envSnapshot.restore();
    +    }
    +  });
    +
    +  test("rejects approving another device from a non-admin paired-device session", async () => {
    +    const started = await startServerWithClient("secret");
    +    const approver = await issueOperatorToken({
    +      name: "approve-cross-device-attacker",
    +      approvedScopes: ["operator.admin"],
    +      tokenScopes: ["operator.pairing"],
    +      clientId: GATEWAY_CLIENT_NAMES.TEST,
    +      clientMode: GATEWAY_CLIENT_MODES.TEST,
    +    });
    +    const pending = loadDeviceIdentity("approve-cross-device-target");
    +
    +    let pairingWs: WebSocket | undefined;
    +    try {
    +      const request = await requestDevicePairing({
    +        deviceId: pending.identity.deviceId,
    +        publicKey: pending.publicKey,
    +        role: "operator",
    +        scopes: ["operator.pairing"],
    +        clientId: GATEWAY_CLIENT_NAMES.TEST,
    +        clientMode: GATEWAY_CLIENT_MODES.TEST,
    +      });
    +
    +      pairingWs = await openTrackedWs(started.port);
    +      await connectOk(pairingWs, {
    +        skipDefaultAuth: true,
    +        deviceToken: approver.token,
    +        deviceIdentityPath: approver.identityPath,
    +        scopes: ["operator.pairing"],
    +      });
    +
    +      const approve = await rpcReq(pairingWs, "device.pair.approve", {
    +        requestId: request.request.requestId,
    +      });
    +      expect(approve.ok).toBe(false);
    +      expect(approve.error?.message).toBe("device pairing approval denied");
    +
           const paired = await getPairedDevice(pending.identity.deviceId);
           expect(paired).toBeNull();
         } finally {
    @@ -62,4 +113,50 @@ describe("gateway device.pair.approve caller scope guard", () => {
           started.envSnapshot.restore();
         }
       });
    +
    +  test("rejects rejecting another device from a non-admin paired-device session", async () => {
    +    const started = await startServerWithClient("secret");
    +    const attacker = await issueOperatorToken({
    +      name: "reject-cross-device-attacker",
    +      approvedScopes: ["operator.admin"],
    +      tokenScopes: ["operator.pairing"],
    +      clientId: GATEWAY_CLIENT_NAMES.TEST,
    +      clientMode: GATEWAY_CLIENT_MODES.TEST,
    +    });
    +    const pending = loadDeviceIdentity("reject-cross-device-target");
    +
    +    let pairingWs: WebSocket | undefined;
    +    try {
    +      const request = await requestDevicePairing({
    +        deviceId: pending.identity.deviceId,
    +        publicKey: pending.publicKey,
    +        role: "operator",
    +        scopes: ["operator.pairing"],
    +        clientId: GATEWAY_CLIENT_NAMES.TEST,
    +        clientMode: GATEWAY_CLIENT_MODES.TEST,
    +      });
    +
    +      pairingWs = await openTrackedWs(started.port);
    +      await connectOk(pairingWs, {
    +        skipDefaultAuth: true,
    +        deviceToken: attacker.token,
    +        deviceIdentityPath: attacker.identityPath,
    +        scopes: ["operator.pairing"],
    +      });
    +
    +      const reject = await rpcReq(pairingWs, "device.pair.reject", {
    +        requestId: request.request.requestId,
    +      });
    +      expect(reject.ok).toBe(false);
    +      expect(reject.error?.message).toBe("device pairing rejection denied");
    +
    +      const stillPending = await getPendingDevicePairing(request.request.requestId);
    +      expect(stillPending).not.toBeNull();
    +    } finally {
    +      pairingWs?.close();
    +      started.ws.close();
    +      await started.server.close();
    +      started.envSnapshot.restore();
    +    }
    +  });
     });
    
  • src/gateway/server-methods/devices.test.ts+346 6 modified
    @@ -3,13 +3,21 @@ import { deviceHandlers } from "./devices.js";
     import type { GatewayRequestHandlerOptions } from "./types.js";
     
     const {
    +  approveDevicePairingMock,
       getPairedDeviceMock,
    +  getPendingDevicePairingMock,
    +  listDevicePairingMock,
       removePairedDeviceMock,
    +  rejectDevicePairingMock,
       revokeDeviceTokenMock,
       rotateDeviceTokenMock,
     } = vi.hoisted(() => ({
    +  approveDevicePairingMock: vi.fn(),
       getPairedDeviceMock: vi.fn(),
    +  getPendingDevicePairingMock: vi.fn(),
    +  listDevicePairingMock: vi.fn(),
       removePairedDeviceMock: vi.fn(),
    +  rejectDevicePairingMock: vi.fn(),
       revokeDeviceTokenMock: vi.fn(),
       rotateDeviceTokenMock: vi.fn(),
     }));
    @@ -20,15 +28,26 @@ vi.mock("../../infra/device-pairing.js", async () => {
       );
       return {
         ...actual,
    +    approveDevicePairing: approveDevicePairingMock,
         getPairedDevice: getPairedDeviceMock,
    +    getPendingDevicePairing: getPendingDevicePairingMock,
    +    listDevicePairing: listDevicePairingMock,
         removePairedDevice: removePairedDeviceMock,
    +    rejectDevicePairing: rejectDevicePairingMock,
         revokeDeviceToken: revokeDeviceTokenMock,
         rotateDeviceToken: rotateDeviceTokenMock,
       };
     });
     
    -function createClient(scopes: string[], deviceId?: string) {
    +function createClient(
    +  scopes: string[],
    +  deviceId?: string,
    +  opts?: {
    +    isDeviceTokenAuth?: boolean;
    +  },
    +) {
       return {
    +    ...(opts?.isDeviceTokenAuth !== undefined ? { isDeviceTokenAuth: opts.isDeviceTokenAuth } : {}),
         connect: {
           scopes,
           ...(deviceId ? { device: { id: deviceId } } : {}),
    @@ -48,6 +67,7 @@ function createOptions(
         isWebchatConnect: () => false,
         respond: vi.fn(),
         context: {
    +      broadcast: vi.fn(),
           disconnectClientsForDevice: vi.fn(),
           logGateway: {
             debug: vi.fn(),
    @@ -129,7 +149,7 @@ describe("deviceHandlers", () => {
         const opts = createOptions(
           "device.pair.remove",
           { deviceId: "device-2" },
    -      { client: createClient(["operator.pairing"], "device-1") },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
         );
     
         await deviceHandlers["device.pair.remove"](opts);
    @@ -147,7 +167,7 @@ describe("deviceHandlers", () => {
         const opts = createOptions(
           "device.pair.remove",
           { deviceId: " device-1 " },
    -      { client: createClient(["operator.pairing"], "device-1") },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
         );
     
         await deviceHandlers["device.pair.remove"](opts);
    @@ -189,7 +209,7 @@ describe("deviceHandlers", () => {
         const opts = createOptions(
           "device.token.revoke",
           { deviceId: "device-2", role: "operator" },
    -      { client: createClient(["operator.admin"], "device-1") },
    +      { client: createClient(["operator.admin"], "device-1", { isDeviceTokenAuth: true }) },
         );
     
         await deviceHandlers["device.token.revoke"](opts);
    @@ -210,7 +230,7 @@ describe("deviceHandlers", () => {
         const opts = createOptions(
           "device.token.revoke",
           { deviceId: " device-1 ", role: "operator" },
    -      { client: createClient(["operator.pairing"], "device-1") },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
         );
     
         await deviceHandlers["device.token.revoke"](opts);
    @@ -279,7 +299,7 @@ describe("deviceHandlers", () => {
             role: "operator",
             scopes: ["operator.pairing"],
           },
    -      { client: createClient(["operator.pairing"], "device-1") },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
         );
     
         await deviceHandlers["device.token.rotate"](opts);
    @@ -346,4 +366,324 @@ describe("deviceHandlers", () => {
           expect.objectContaining({ message: "unknown deviceId/role" }),
         );
       });
    +
    +  it("filters pairing list to the caller device for non-admin device sessions", async () => {
    +    listDevicePairingMock.mockResolvedValue({
    +      pending: [
    +        { requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
    +        { requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
    +      ],
    +      paired: [
    +        {
    +          deviceId: "device-1",
    +          publicKey: "pk-1",
    +          approvedAtMs: 100,
    +          createdAtMs: 50,
    +        },
    +        {
    +          deviceId: "device-2",
    +          publicKey: "pk-2",
    +          approvedAtMs: 200,
    +          createdAtMs: 60,
    +        },
    +      ],
    +    });
    +    const opts = createOptions(
    +      "device.pair.list",
    +      {},
    +      {
    +        client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.list"](opts);
    +
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      {
    +        pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
    +        paired: [
    +          {
    +            deviceId: "device-1",
    +            publicKey: "pk-1",
    +            approvedAtMs: 100,
    +            createdAtMs: 50,
    +            tokens: undefined,
    +          },
    +        ],
    +      },
    +      undefined,
    +    );
    +  });
    +
    +  it("preserves the full pairing list for admin device sessions", async () => {
    +    listDevicePairingMock.mockResolvedValue({
    +      pending: [
    +        { requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
    +        { requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
    +      ],
    +      paired: [
    +        { deviceId: "device-1", publicKey: "pk-1", approvedAtMs: 100, createdAtMs: 50 },
    +        { deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 },
    +      ],
    +    });
    +    const opts = createOptions(
    +      "device.pair.list",
    +      {},
    +      {
    +        client: createClient(["operator.pairing", "operator.admin"], "device-1", {
    +          isDeviceTokenAuth: true,
    +        }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.list"](opts);
    +
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      {
    +        pending: [
    +          { requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
    +          { requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
    +        ],
    +        paired: [
    +          {
    +            deviceId: "device-1",
    +            publicKey: "pk-1",
    +            approvedAtMs: 100,
    +            createdAtMs: 50,
    +            tokens: undefined,
    +          },
    +          {
    +            deviceId: "device-2",
    +            publicKey: "pk-2",
    +            approvedAtMs: 200,
    +            createdAtMs: 60,
    +            tokens: undefined,
    +          },
    +        ],
    +      },
    +      undefined,
    +    );
    +  });
    +
    +  it("preserves the full pairing list for non-device operator sessions", async () => {
    +    listDevicePairingMock.mockResolvedValue({
    +      pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
    +      paired: [{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 }],
    +    });
    +    const opts = createOptions(
    +      "device.pair.list",
    +      {},
    +      {
    +        client: createClient(["operator.pairing"]),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.list"](opts);
    +
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      {
    +        pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 }],
    +        paired: [
    +          {
    +            deviceId: "device-2",
    +            publicKey: "pk-2",
    +            approvedAtMs: 200,
    +            createdAtMs: 60,
    +            tokens: undefined,
    +          },
    +        ],
    +      },
    +      undefined,
    +    );
    +  });
    +
    +  it("preserves the full pairing list for shared-auth sessions carrying a device identity", async () => {
    +    listDevicePairingMock.mockResolvedValue({
    +      pending: [
    +        { requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
    +        { requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
    +      ],
    +      paired: [{ deviceId: "device-2", publicKey: "pk-2", approvedAtMs: 200, createdAtMs: 60 }],
    +    });
    +    const opts = createOptions(
    +      "device.pair.list",
    +      {},
    +      {
    +        client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.list"](opts);
    +
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      {
    +        pending: [
    +          { requestId: "req-1", deviceId: "device-1", publicKey: "pk-1", ts: 100 },
    +          { requestId: "req-2", deviceId: "device-2", publicKey: "pk-2", ts: 200 },
    +        ],
    +        paired: [
    +          {
    +            deviceId: "device-2",
    +            publicKey: "pk-2",
    +            approvedAtMs: 200,
    +            createdAtMs: 60,
    +            tokens: undefined,
    +          },
    +        ],
    +      },
    +      undefined,
    +    );
    +  });
    +
    +  it("rejects approving another device from a non-admin device session", async () => {
    +    getPendingDevicePairingMock.mockResolvedValue({
    +      requestId: "req-2",
    +      deviceId: "device-2",
    +      publicKey: "pk-2",
    +      ts: 100,
    +    });
    +    const opts = createOptions(
    +      "device.pair.approve",
    +      { requestId: "req-2" },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
    +    );
    +
    +    await deviceHandlers["device.pair.approve"](opts);
    +
    +    expect(approveDevicePairingMock).not.toHaveBeenCalled();
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      false,
    +      undefined,
    +      expect.objectContaining({ message: "device pairing approval denied" }),
    +    );
    +  });
    +
    +  it("allows approving the caller device from a non-admin device session", async () => {
    +    getPendingDevicePairingMock.mockResolvedValue({
    +      requestId: "req-1",
    +      deviceId: " device-1 ",
    +      publicKey: "pk-1",
    +      ts: 100,
    +    });
    +    approveDevicePairingMock.mockResolvedValue({
    +      status: "approved",
    +      requestId: "req-1",
    +      device: {
    +        deviceId: "device-1",
    +        publicKey: "pk-1",
    +        approvedAtMs: 100,
    +        createdAtMs: 50,
    +      },
    +    });
    +    const opts = createOptions(
    +      "device.pair.approve",
    +      { requestId: "req-1" },
    +      { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
    +    );
    +
    +    await deviceHandlers["device.pair.approve"](opts);
    +
    +    expect(approveDevicePairingMock).toHaveBeenCalledWith("req-1", {
    +      callerScopes: ["operator.pairing"],
    +    });
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      {
    +        requestId: "req-1",
    +        device: {
    +          deviceId: "device-1",
    +          publicKey: "pk-1",
    +          approvedAtMs: 100,
    +          createdAtMs: 50,
    +          tokens: undefined,
    +        },
    +      },
    +      undefined,
    +    );
    +  });
    +
    +  it("rejects rejecting another device from a non-admin device session", async () => {
    +    getPendingDevicePairingMock.mockResolvedValue({
    +      requestId: "req-2",
    +      deviceId: "device-2",
    +      publicKey: "pk-2",
    +      ts: 100,
    +    });
    +    const opts = createOptions(
    +      "device.pair.reject",
    +      { requestId: "req-2" },
    +      {
    +        client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.reject"](opts);
    +
    +    expect(rejectDevicePairingMock).not.toHaveBeenCalled();
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      false,
    +      undefined,
    +      expect.objectContaining({ message: "device pairing rejection denied" }),
    +    );
    +  });
    +
    +  it("allows rejecting the caller device from a non-admin device session", async () => {
    +    getPendingDevicePairingMock.mockResolvedValue({
    +      requestId: "req-1",
    +      deviceId: " device-1 ",
    +      publicKey: "pk-1",
    +      ts: 100,
    +    });
    +    rejectDevicePairingMock.mockResolvedValue({
    +      requestId: "req-1",
    +      deviceId: "device-1",
    +      rejectedAtMs: 123,
    +    });
    +    const opts = createOptions(
    +      "device.pair.reject",
    +      { requestId: "req-1" },
    +      {
    +        client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.reject"](opts);
    +
    +    expect(rejectDevicePairingMock).toHaveBeenCalledWith("req-1");
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      { requestId: "req-1", deviceId: "device-1", rejectedAtMs: 123 },
    +      undefined,
    +    );
    +  });
    +
    +  it("allows admins to reject another device", async () => {
    +    rejectDevicePairingMock.mockResolvedValue({
    +      requestId: "req-2",
    +      deviceId: "device-2",
    +      rejectedAtMs: 456,
    +    });
    +    const opts = createOptions(
    +      "device.pair.reject",
    +      { requestId: "req-2" },
    +      {
    +        client: createClient(["operator.pairing", "operator.admin"], "device-1", {
    +          isDeviceTokenAuth: true,
    +        }),
    +      },
    +    );
    +
    +    await deviceHandlers["device.pair.reject"](opts);
    +
    +    expect(rejectDevicePairingMock).toHaveBeenCalledWith("req-2");
    +    expect(opts.respond).toHaveBeenCalledWith(
    +      true,
    +      { requestId: "req-2", deviceId: "device-2", rejectedAtMs: 456 },
    +      undefined,
    +    );
    +  });
     });
    
  • src/gateway/server-methods/devices.ts+77 9 modified
    @@ -2,6 +2,7 @@ import {
       approveDevicePairing,
       formatDevicePairingForbiddenMessage,
       getPairedDevice,
    +  getPendingDevicePairing,
       listApprovedPairedDeviceRoles,
       listDevicePairing,
       removePairedDevice,
    @@ -34,13 +35,19 @@ type DeviceTokenRotateTarget = {
       normalizedRole: string;
     };
     
    -type DeviceManagementAuthz = {
    +type DeviceSessionAuthz = {
       callerDeviceId: string | null;
       callerScopes: string[];
       isAdminCaller: boolean;
    +};
    +
    +type DeviceManagementAuthz = DeviceSessionAuthz & {
       normalizedTargetDeviceId: string;
     };
     
    +const DEVICE_PAIR_APPROVAL_DENIED_MESSAGE = "device pairing approval denied";
    +const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied";
    +
     function redactPairedDevice(
       device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
     ) {
    @@ -91,17 +98,23 @@ function resolveDeviceManagementAuthz(
       client: GatewayClient | null,
       targetDeviceId: string,
     ): DeviceManagementAuthz {
    +  return {
    +    ...resolveDeviceSessionAuthz(client),
    +    normalizedTargetDeviceId: targetDeviceId.trim(),
    +  };
    +}
    +
    +function resolveDeviceSessionAuthz(client: GatewayClient | null): DeviceSessionAuthz {
       const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
       const rawCallerDeviceId = client?.connect?.device?.id;
       const callerDeviceId =
    -    typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim()
    +    client?.isDeviceTokenAuth && typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim()
           ? rawCallerDeviceId.trim()
           : null;
       return {
         callerDeviceId,
         callerScopes,
         isAdminCaller: callerScopes.includes("operator.admin"),
    -    normalizedTargetDeviceId: targetDeviceId.trim(),
       };
     }
     
    @@ -114,7 +127,7 @@ function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean {
     }
     
     export const deviceHandlers: GatewayRequestHandlers = {
    -  "device.pair.list": async ({ params, respond }) => {
    +  "device.pair.list": async ({ params, respond, client }) => {
         if (!validateDevicePairListParams(params)) {
           respond(
             false,
    @@ -129,11 +142,21 @@ export const deviceHandlers: GatewayRequestHandlers = {
           return;
         }
         const list = await listDevicePairing();
    +    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;
         respond(
           true,
           {
    -        pending: list.pending,
    -        paired: list.paired.map((device) => redactPairedDevice(device)),
    +        pending: visibleList.pending,
    +        paired: visibleList.paired.map((device) => redactPairedDevice(device)),
           },
           undefined,
         );
    @@ -153,8 +176,30 @@ export const deviceHandlers: GatewayRequestHandlers = {
           return;
         }
         const { requestId } = params as { requestId: string };
    -    const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
    -    const approved = await approveDevicePairing(requestId, { callerScopes });
    +    const authz = resolveDeviceSessionAuthz(client);
    +    if (authz.callerDeviceId && !authz.isAdminCaller) {
    +      const pending = await getPendingDevicePairing(requestId);
    +      if (!pending) {
    +        respond(
    +          false,
    +          undefined,
    +          errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE),
    +        );
    +        return;
    +      }
    +      if (pending.deviceId.trim() !== authz.callerDeviceId) {
    +        context.logGateway.warn(
    +          `device pairing approval denied request=${requestId} reason=device-ownership-mismatch`,
    +        );
    +        respond(
    +          false,
    +          undefined,
    +          errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE),
    +        );
    +        return;
    +      }
    +    }
    +    const approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes });
         if (!approved) {
           respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
           return;
    @@ -182,7 +227,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
         );
         respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
       },
    -  "device.pair.reject": async ({ params, respond, context }) => {
    +  "device.pair.reject": async ({ params, respond, context, client }) => {
         if (!validateDevicePairRejectParams(params)) {
           respond(
             false,
    @@ -197,6 +242,29 @@ export const deviceHandlers: GatewayRequestHandlers = {
           return;
         }
         const { requestId } = params as { requestId: string };
    +    const authz = resolveDeviceSessionAuthz(client);
    +    if (authz.callerDeviceId && !authz.isAdminCaller) {
    +      const pending = await getPendingDevicePairing(requestId);
    +      if (!pending) {
    +        respond(
    +          false,
    +          undefined,
    +          errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE),
    +        );
    +        return;
    +      }
    +      if (pending.deviceId.trim() !== authz.callerDeviceId) {
    +        context.logGateway.warn(
    +          `device pairing rejection denied request=${requestId} reason=device-ownership-mismatch`,
    +        );
    +        respond(
    +          false,
    +          undefined,
    +          errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE),
    +        );
    +        return;
    +      }
    +    }
         const rejected = await rejectDevicePairing(requestId);
         if (!rejected) {
           respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
    
  • src/gateway/server-methods/shared-types.ts+1 0 modified
    @@ -23,6 +23,7 @@ export type GatewayClient = {
       canvasHostUrl?: string;
       canvasCapability?: string;
       canvasCapabilityExpiresAtMs?: number;
    +  isDeviceTokenAuth?: boolean;
       internal?: {
         allowModelOverride?: boolean;
       };
    
  • src/gateway/server/ws-connection/message-handler.ts+1 0 modified
    @@ -1303,6 +1303,7 @@ export function attachGatewayWsMessageHandler(params: {
               socket,
               connect: connectParams,
               connId,
    +          isDeviceTokenAuth: authMethod === "device-token",
               usesSharedGatewayAuth,
               sharedGatewaySessionGeneration,
               presenceKey,
    
  • src/gateway/server/ws-types.ts+1 0 modified
    @@ -5,6 +5,7 @@ export type GatewayWsClient = {
       socket: WebSocket;
       connect: ConnectParams;
       connId: string;
    +  isDeviceTokenAuth?: boolean;
       usesSharedGatewayAuth: boolean;
       sharedGatewaySessionGeneration?: string;
       presenceKey?: string;
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.