mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 12:17:26 +00:00
refactor(gateway): unify metadata canonicalization + platform rules
This commit is contained in:
@@ -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 ?? "";
|
||||
|
||||
31
src/gateway/device-metadata-normalization.ts
Normal file
31
src/gateway/device-metadata-normalization.ts
Normal 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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user