diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index bba04488ffc..5bbd430ee2d 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -3,7 +3,8 @@ import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js"; @@ -264,6 +265,33 @@ describe("handleControlUiHttpRequest", () => { } } + async function withPairedOperatorDeviceToken(params: { fn: (token: string) => Promise }) { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-device-token-")); + vi.stubEnv("OPENCLAW_HOME", tempHome); + try { + const deviceId = "control-ui-device"; + const requested = await requestDevicePairing({ + deviceId, + publicKey: "test-public-key", + role: "operator", + scopes: ["operator.read"], + clientId: "openclaw-control-ui", + clientMode: "webchat", + }); + const approved = await approveDevicePairing(requested.request.requestId, { + callerScopes: ["operator.read"], + }); + expect(approved?.status).toBe("approved"); + const operatorToken = + approved?.status === "approved" ? approved.device.tokens?.operator?.token : undefined; + expect(typeof operatorToken).toBe("string"); + return await params.fn(operatorToken ?? ""); + } finally { + vi.unstubAllEnvs(); + await fs.rm(tempHome, { recursive: true, force: true }); + } + } + it("sets security headers for Control UI responses", async () => { await withControlUiRoot({ fn: async (tmp) => { @@ -370,6 +398,51 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("accepts paired operator device tokens on assistant media requests", async () => { + await withPairedOperatorDeviceToken({ + fn: async (operatorToken) => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-device-token-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { mode: "token", token: "shared-token", allowTailscale: false }, + headers: { + authorization: `Bearer ${operatorToken}`, + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }, + }); + }, + }); + }); + + it("accepts paired operator device tokens in assistant media query auth", async () => { + await withPairedOperatorDeviceToken({ + fn: async (operatorToken) => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-device-token-query-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=${encodeURIComponent(operatorToken)}`, + method: "GET", + auth: { mode: "token", token: "shared-token", allowTailscale: false }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }, + }); + }, + }); + }); + it("rejects trusted-proxy assistant media requests from disallowed browser origins", async () => { await withAllowedAssistantMediaRoot({ prefix: "ui-media-proxy-", @@ -526,6 +599,28 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("serves bootstrap config JSON when paired device-token auth is valid", async () => { + await withPairedOperatorDeviceToken({ + fn: async (operatorToken) => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ + rootPath: tmp, + auth: { mode: "token", token: "shared-token", allowTailscale: false }, + headers: { + authorization: `Bearer ${operatorToken}`, + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const parsed = parseBootstrapPayload(end); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }, + }); + }); + it("serves bootstrap config JSON under basePath", async () => { await withControlUiRoot({ fn: async (tmp) => { @@ -618,6 +713,34 @@ describe("handleControlUiHttpRequest", () => { } }); + it("serves local avatar bytes when paired device-token auth is valid", async () => { + await withPairedOperatorDeviceToken({ + fn: async (operatorToken) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-device-token-")); + try { + const avatarPath = path.join(tmp, "main.png"); + await fs.writeFile(avatarPath, "avatar-bytes\n"); + + const { res, handled, end } = await runAvatarRequest({ + url: "/avatar/main", + method: "GET", + auth: { mode: "token", token: "shared-token", allowTailscale: false }, + headers: { + authorization: `Bearer ${operatorToken}`, + }, + resolveAvatar: () => ({ kind: "local", filePath: avatarPath }), + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("avatar-bytes\n"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }, + }); + }); + it("returns avatar metadata when auth is enabled and the token is valid", async () => { const { res, end, handled } = await runAvatarRequest({ url: "/avatar/main?meta=1", diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 5673b29d6ee..435563b5237 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -7,8 +7,10 @@ import { isPackageProvenControlUiRootSync, resolveControlUiRootSync, } from "../infra/control-ui-assets.js"; +import { listDevicePairing, verifyDeviceToken } from "../infra/device-pairing.js"; import { openLocalFileSafely, SafeOpenError } from "../infra/fs-safe.js"; import { safeFileURLToPath } from "../infra/local-file-access.js"; +import { verifyPairingToken } from "../infra/pairing-token.js"; import { isWithinDir } from "../infra/path-safety.js"; import { openVerifiedFileSync } from "../infra/safe-open-sync.js"; import { assertLocalMediaAllowed, getDefaultLocalRoots } from "../media/local-media-access.js"; @@ -18,7 +20,11 @@ import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; import { resolveUserPath } from "../utils.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; -import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import { + AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + type AuthRateLimiter, +} from "./auth-rate-limit.js"; import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, @@ -44,11 +50,14 @@ import { resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; +import { resolveRequestClientIp } from "./net.js"; const ROOT_PREFIX = "/"; const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media"; const CONTROL_UI_ASSETS_MISSING_MESSAGE = "Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development."; +const CONTROL_UI_OPERATOR_READ_SCOPE = "operator.read"; +const CONTROL_UI_OPERATOR_ROLE = "operator"; export type ControlUiRequestOptions = { basePath?: string; @@ -247,6 +256,9 @@ async function authorizeControlUiReadRequest( const token = resolveControlUiReadAuthToken(req, { allowQueryToken: opts.allowQueryToken, }); + const clientIp = + resolveRequestClientIp(req, opts.trustedProxies, opts.allowRealIpFallback === true) ?? + req.socket?.remoteAddress; const authResult = await authorizeHttpGatewayConnect({ auth: opts.auth, connectAuth: token ? { token, password: token } : null, @@ -254,17 +266,42 @@ async function authorizeControlUiReadRequest( browserOriginPolicy: resolveHttpBrowserOriginPolicy(req), trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, - rateLimiter: opts.rateLimiter, + rateLimiter: token ? opts.rateLimiter : undefined, + clientIp, + rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); + let resolvedAuthResult = authResult; + if ( + !resolvedAuthResult.ok && + token && + opts.auth.mode !== "trusted-proxy" && + opts.auth.mode !== "none" + ) { + const deviceRateCheck = opts.rateLimiter?.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + if (deviceRateCheck && !deviceRateCheck.allowed) { + resolvedAuthResult = { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: deviceRateCheck.retryAfterMs, + }; + } else { + const deviceTokenOk = await authorizeControlUiDeviceReadToken(token); + if (deviceTokenOk) { + opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + resolvedAuthResult = { ok: true, method: "device-token" }; + } else { + opts.rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + } + } + } + if (!resolvedAuthResult.ok) { + sendGatewayAuthFailure(res, resolvedAuthResult); return false; } - const trustDeclaredOperatorScopes = - authResult.method !== "token" && - authResult.method !== "password" && - authResult.method !== "none"; + const trustDeclaredOperatorScopes = resolvedAuthResult.method === "trusted-proxy"; if (!trustDeclaredOperatorScopes) { return true; } @@ -287,6 +324,29 @@ async function authorizeControlUiReadRequest( return true; } +async function authorizeControlUiDeviceReadToken(token: string): Promise { + const pairing = await listDevicePairing(); + for (const device of pairing.paired) { + const operatorToken = device.tokens?.[CONTROL_UI_OPERATOR_ROLE]; + if (!operatorToken || operatorToken.revokedAtMs) { + continue; + } + if (!verifyPairingToken(token, operatorToken.token)) { + continue; + } + const verified = await verifyDeviceToken({ + deviceId: device.deviceId, + token, + role: CONTROL_UI_OPERATOR_ROLE, + scopes: [CONTROL_UI_OPERATOR_READ_SCOPE], + }); + if (verified.ok) { + return true; + } + } + return false; +} + type AssistantMediaAvailability = | { available: true } | { available: false; reason: string; code: string };