mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
fix(control ui): accept paired device token for assistant media auth (#70741)
* fix control ui assistant media auth * test control ui device token read routes * defer assistant attachment blob downloads * fix grouped render rebase merge * evict stale assistant attachment blob urls * fix assistant media device token query auth
This commit is contained in:
@@ -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<T>(params: { fn: (token: string) => Promise<T> }) {
|
||||
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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user