mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
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 <drobison@nvidia.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -23,6 +23,7 @@ export type GatewayClient = {
|
||||
canvasHostUrl?: string;
|
||||
canvasCapability?: string;
|
||||
canvasCapabilityExpiresAtMs?: number;
|
||||
isDeviceTokenAuth?: boolean;
|
||||
internal?: {
|
||||
allowModelOverride?: boolean;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1303,6 +1303,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
socket,
|
||||
connect: connectParams,
|
||||
connId,
|
||||
isDeviceTokenAuth: authMethod === "device-token",
|
||||
usesSharedGatewayAuth,
|
||||
sharedGatewaySessionGeneration,
|
||||
presenceKey,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type GatewayWsClient = {
|
||||
socket: WebSocket;
|
||||
connect: ConnectParams;
|
||||
connId: string;
|
||||
isDeviceTokenAuth?: boolean;
|
||||
usesSharedGatewayAuth: boolean;
|
||||
sharedGatewaySessionGeneration?: string;
|
||||
presenceKey?: string;
|
||||
|
||||
Reference in New Issue
Block a user