refactor(gateway): unify metadata canonicalization + platform rules

This commit is contained in:
Peter Steinberger
2026-03-02 00:26:28 +00:00
parent 0c0f556927
commit 9005e8bc0a
4 changed files with 160 additions and 56 deletions

View File

@@ -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 ?? "";

View File

@@ -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();
}

View File

@@ -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<string, string[]> = {
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<PlatformId, "unknown">;
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<PlatformId, "unknown">;
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<PlatformId, "unknown"> | 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<PlatformId, "unknown"> | 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(

View File

@@ -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();
}
}
});
});