From 5a12f30441d5b0b151f550daa2c5c9e8db61e2e6 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:39 -0700 Subject: [PATCH] Limit paired-device pairing actions to the caller device (#69375) * fix(pairing): restrict paired-device pairing actions * fix(pairing): close device authz review gaps * docs(changelog): note device-pair scoping for non-admin paired devices (#69375) --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + src/gateway/server-methods/devices.test.ts | 352 +++++++++++++++++- src/gateway/server-methods/devices.ts | 86 ++++- src/gateway/server-methods/shared-types.ts | 1 + .../server.device-pair-approve-authz.test.ts | 105 +++++- .../server/ws-connection/message-handler.ts | 1 + src/gateway/server/ws-types.ts | 1 + 7 files changed, 528 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a613fd3fd..46ff90a4fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index a8e1c1a98e9..e2f065dc636 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -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, + ); + }); }); diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 6cb29dd18ef..1ce19ed4c2d 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -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 } & Record, ) { @@ -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")); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 1d5e8de8298..0d0573a28c8 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -23,6 +23,7 @@ export type GatewayClient = { canvasHostUrl?: string; canvasCapability?: string; canvasCapabilityExpiresAtMs?: number; + isDeviceTokenAuth?: boolean; internal?: { allowModelOverride?: boolean; }; diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index a13f17e593c..7edb72fb8ad 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -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(); + } + }); }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index edddc8082d5..07bc5398563 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1303,6 +1303,7 @@ export function attachGatewayWsMessageHandler(params: { socket, connect: connectParams, connId, + isDeviceTokenAuth: authMethod === "device-token", usesSharedGatewayAuth, sharedGatewaySessionGeneration, presenceKey, diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index fe6da6c5778..a7f3cb4e6df 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -5,6 +5,7 @@ export type GatewayWsClient = { socket: WebSocket; connect: ConnectParams; connId: string; + isDeviceTokenAuth?: boolean; usesSharedGatewayAuth: boolean; sharedGatewaySessionGeneration?: string; presenceKey?: string;