diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index e0ef2c4eeec..217cc51fdf0 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -1,3 +1,6 @@ +import { normalizeDeviceMetadataForAuth } from "./device-metadata-normalization.js"; +export { normalizeDeviceMetadataForAuth }; + export type DeviceAuthPayloadParams = { deviceId: string; clientId: string; @@ -14,23 +17,6 @@ export type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & { deviceFamily?: string | null; }; -function toLowerAscii(input: string): string { - return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); -} - -export function normalizeDeviceMetadataForAuth(value?: string | null): string { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin) by only - // lowercasing ASCII metadata fields used in auth payloads. - return toLowerAscii(trimmed); -} - export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { const scopes = params.scopes.join(","); const token = params.token ?? ""; diff --git a/src/gateway/device-metadata-normalization.ts b/src/gateway/device-metadata-normalization.ts new file mode 100644 index 00000000000..e97f77a85c0 --- /dev/null +++ b/src/gateway/device-metadata-normalization.ts @@ -0,0 +1,31 @@ +function normalizeTrimmedMetadata(value?: string | null): string { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed ? trimmed : ""; +} + +function toLowerAscii(input: string): string { + return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); +} + +export function normalizeDeviceMetadataForAuth(value?: string | null): string { + const trimmed = normalizeTrimmedMetadata(value); + if (!trimmed) { + return ""; + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin) by only + // lowercasing ASCII metadata fields used in auth payloads. + return toLowerAscii(trimmed); +} + +export function normalizeDeviceMetadataForPolicy(value?: string | null): string { + const trimmed = normalizeTrimmedMetadata(value); + if (!trimmed) { + return ""; + } + // Policy classification should collapse Unicode confusables to stable ASCII-ish + // tokens where possible before matching platform/family rules. + return trimmed.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase(); +} diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 1429f71a823..5f6734f6f7f 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -4,6 +4,7 @@ import { NODE_SYSTEM_NOTIFY_COMMAND, NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; +import { normalizeDeviceMetadataForPolicy } from "./device-metadata-normalization.js"; import type { NodeSession } from "./node-registry.js"; const CANVAS_COMMANDS = [ @@ -114,50 +115,59 @@ const PLATFORM_DEFAULTS: Record = { unknown: [...UNKNOWN_PLATFORM_COMMANDS], }; -function normalizePlatformToken(value?: string): string { - if (typeof value !== "string") { - return ""; +type PlatformId = "ios" | "android" | "macos" | "windows" | "linux" | "unknown"; + +const PLATFORM_PREFIX_RULES: ReadonlyArray<{ + id: Exclude; + prefixes: readonly string[]; +}> = [ + { id: "ios", prefixes: ["ios"] }, + { id: "android", prefixes: ["android"] }, + { id: "macos", prefixes: ["mac", "darwin"] }, + { id: "windows", prefixes: ["win"] }, + { id: "linux", prefixes: ["linux"] }, +] as const; + +const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{ + id: Exclude; + tokens: readonly string[]; +}> = [ + { id: "ios", tokens: ["iphone", "ipad", "ios"] }, + { id: "android", tokens: ["android"] }, + { id: "macos", tokens: ["mac"] }, + { id: "windows", tokens: ["windows"] }, + { id: "linux", tokens: ["linux"] }, +] as const; + +function resolvePlatformIdByPrefix(value: string): Exclude | undefined { + for (const rule of PLATFORM_PREFIX_RULES) { + if (rule.prefixes.some((prefix) => value.startsWith(prefix))) { + return rule.id; + } } - return value.trim().normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase(); + return undefined; } -function normalizePlatformId(platform?: string, deviceFamily?: string): string { - const raw = normalizePlatformToken(platform); - if (raw.startsWith("ios")) { - return "ios"; +function resolvePlatformIdByDeviceFamily( + value: string, +): Exclude | undefined { + for (const rule of DEVICE_FAMILY_TOKEN_RULES) { + if (rule.tokens.some((token) => value.includes(token))) { + return rule.id; + } } - if (raw.startsWith("android")) { - return "android"; + return undefined; +} + +function normalizePlatformId(platform?: string, deviceFamily?: string): PlatformId { + const raw = normalizeDeviceMetadataForPolicy(platform); + const byPlatform = resolvePlatformIdByPrefix(raw); + if (byPlatform) { + return byPlatform; } - if (raw.startsWith("mac")) { - return "macos"; - } - if (raw.startsWith("darwin")) { - return "macos"; - } - if (raw.startsWith("win")) { - return "windows"; - } - if (raw.startsWith("linux")) { - return "linux"; - } - const family = normalizePlatformToken(deviceFamily); - if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) { - return "ios"; - } - if (family.includes("android")) { - return "android"; - } - if (family.includes("mac")) { - return "macos"; - } - if (family.includes("windows")) { - return "windows"; - } - if (family.includes("linux")) { - return "linux"; - } - return "unknown"; + const family = normalizeDeviceMetadataForPolicy(deviceFamily); + const byFamily = resolvePlatformIdByDeviceFamily(family); + return byFamily ?? "unknown"; } export function resolveNodeCommandAllowlist( diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 8b78ced9b47..87dfc400cc5 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -366,4 +366,81 @@ describe("gateway node command allowlist", () => { iosClient?.stop(); } }); + + test("filters system.run for confusable iOS metadata at connect time", async () => { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const cases = [ + { + label: "dotted-i-platform", + platform: "İOS", + deviceFamily: "iPhone", + }, + { + label: "greek-omicron-family", + platform: "ios", + deviceFamily: "iPhοne", + }, + ] as const; + + for (const testCase of cases) { + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-confusable-node-${testCase.label}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath); + const displayName = `node-${testCase.label}`; + + const findConnectedNode = async () => { + const listRes = await rpcReq<{ + nodes?: Array<{ + nodeId: string; + displayName?: string; + connected?: boolean; + commands?: string[]; + }>; + }>(ws, "node.list", {}); + return (listRes.payload?.nodes ?? []).find( + (node) => node.connected && node.displayName === displayName, + ); + }; + + let client: GatewayClient | undefined; + try { + client = await connectNodeClientWithPairing({ + port, + commands: ["system.run", "canvas.snapshot"], + platform: testCase.platform, + deviceFamily: testCase.deviceFamily, + instanceId: displayName, + displayName, + deviceIdentity, + }); + + await expect + .poll( + async () => { + const node = await findConnectedNode(); + return node?.commands?.toSorted() ?? []; + }, + { timeout: 2_000, interval: 10 }, + ) + .toEqual(["canvas.snapshot"]); + + const node = await findConnectedNode(); + const nodeId = node?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + + const systemRunRes = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.run", + params: { command: "echo blocked" }, + idempotencyKey: `allowlist-confusable-${testCase.label}`, + }); + expect(systemRunRes.ok).toBe(false); + expect(systemRunRes.error?.message ?? "").toContain("node command not allowed"); + } finally { + client?.stop(); + } + } + }); });