diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a96348517f..0d8981242672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes -- **Standalone browser relay:** ship a gateway-free extension relay daemon (`dist/extensions/browser/relay-daemon-entry.js`) and teach the native messaging host a rate-limited `ensure_relay` op, so the Chrome extension can wake the relay on demand and CDP clients (mcporter, Playwright) drive the paired browser without a running Gateway. The standalone daemon is v2-only by default (it never re-enables legacy one-directional auth unless `browser.extensionRelay.allowLegacyAuth=true` is set explicitly), so a process that squats the relay port cannot harvest the secret from a legacy client. +- **Standalone browser relay:** ship a gateway-free extension relay daemon (`dist/extensions/browser/relay-daemon-entry.js`) and teach the native messaging host a rate-limited `ensure_relay` op, so the Chrome extension can wake the relay on demand and CDP clients (mcporter, Playwright) drive the paired browser without a running Gateway. The standalone daemon is v2-only by default (it never re-enables legacy one-directional auth unless `browser.extensionRelay.allowLegacyAuth=true` is set explicitly), so a process that squats the relay port cannot harvest the secret from a legacy client. The relay secret is also re-checked for owner and `0600` mode on every read (self-healing a drifted mode, refusing a foreign-owned or non-regular file), so a permission drift on a shared host can no longer expose it. - **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress. - **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts. - **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched. diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.test.ts b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts index 44c95cd80b00..ba51297482c6 100644 --- a/extensions/browser/src/browser/extension-relay/relay-auth.test.ts +++ b/extensions/browser/src/browser/extension-relay/relay-auth.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + classifyRelaySecretPrivacy, ensureExtensionRelayToken, readExtensionRelayToken, resolveExtensionRelayToken, @@ -68,4 +69,49 @@ describe("extension relay host-local secret", () => { fs.rmSync(otherDir, { recursive: true, force: true }); } }); + + const secretFilePath = (): string => + path.join(stateDir, "credentials", "browser-extension-relay.secret"); + + it.runIf(process.platform !== "win32")( + "self-heals a group/other-readable secret to 0600 and still reads it", + async () => { + const token = await ensureExtensionRelayToken(); + const secretPath = secretFilePath(); + fs.chmodSync(secretPath, 0o644); + // Reading tightens the mode back to private and still returns the token. + expect(readExtensionRelayToken()).toBe(token); + expect(fs.statSync(secretPath).mode & 0o777).toBe(0o600); + }, + ); + + it("refuses a symlinked secret", async () => { + const token = await ensureExtensionRelayToken(); + const secretPath = secretFilePath(); + const realTarget = path.join(stateDir, "elsewhere.secret"); + fs.renameSync(secretPath, realTarget); + fs.symlinkSync(realTarget, secretPath); + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(readExtensionRelayToken()).toBeNull(); + }); +}); + +describe("classifyRelaySecretPrivacy", () => { + it("accepts a private, self-owned file", () => { + expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o600 }, 501, "linux")).toBe("ok"); + }); + + it("flags a self-owned file with broad mode for healing", () => { + expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o644 }, 501, "linux")).toBe("heal"); + expect(classifyRelaySecretPrivacy({ uid: 501, mode: 0o660 }, 501, "linux")).toBe("heal"); + }); + + it("refuses a foreign-owned file regardless of mode", () => { + expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o600 }, 501, "linux")).toBe("refuse"); + }); + + it("trusts Windows ACLs and an unknown uid instead of POSIX bits", () => { + expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o777 }, 501, "win32")).toBe("ok"); + expect(classifyRelaySecretPrivacy({ uid: 0, mode: 0o777 }, undefined, "linux")).toBe("ok"); + }); }); diff --git a/extensions/browser/src/browser/extension-relay/relay-auth.ts b/extensions/browser/src/browser/extension-relay/relay-auth.ts index e44713b17cc0..dfc7a7d7fc46 100644 --- a/extensions/browser/src/browser/extension-relay/relay-auth.ts +++ b/extensions/browser/src/browser/extension-relay/relay-auth.ts @@ -8,17 +8,18 @@ * Chrome, and no gateway credential ever has to travel to a node. */ import crypto from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; -import { - createSecretFileAtomic, - readSecretFile, - tryReadSecretFileSync, -} from "openclaw/plugin-sdk/secret-file"; +import { createSecretFileAtomic, tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file"; import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; + +const log = createSubsystemLogger("browser").child("extension-relay"); const RELAY_SECRET_FILE = "browser-extension-relay.secret"; const RELAY_SECRET_REREAD_ATTEMPTS = 50; const RELAY_SECRET_REREAD_DELAY_MS = 10; +const PRIVATE_SECRET_FILE_MODE = 0o600; // resolveOAuthDir returns `${stateDir}/credentials`, the shared credentials dir. function resolveExtensionRelaySecretPath(env: NodeJS.ProcessEnv = process.env): string { @@ -30,12 +31,79 @@ function normalizeToken(raw: string): string | null { return /^[0-9a-f]{64}$/.test(value) ? value : null; } +/** + * The relay secret is the whole auth model: anyone who can read it drives the + * user's real browser, and loopback is not a trust boundary on a multi-user + * host. The fs-safe reader rejects symlinks/hardlinks but does not re-check the + * file mode or owner on read, so a secret whose permissions drifted + * group/other-readable (loosened umask, restore, shared home) would still be + * trusted. Classify the file's privacy before use. + */ +export type RelaySecretPrivacy = "ok" | "heal" | "refuse"; + +/** + * Pure privacy decision for a secret file's stat. `heal`: we own it but the + * mode is too broad — tighten to 0600 and continue. `refuse`: owned by another + * user (never trust a foreign-owned credential). Windows uses ACLs, not POSIX + * mode bits, and the create path establishes them, so it is always `ok` here. + */ +export function classifyRelaySecretPrivacy( + stat: { uid: number; mode: number }, + selfUid: number | undefined, + platform: NodeJS.Platform = process.platform, +): RelaySecretPrivacy { + if (platform === "win32" || selfUid === undefined) { + return "ok"; + } + if (stat.uid !== selfUid) { + return "refuse"; + } + return (stat.mode & 0o077) === 0 ? "ok" : "heal"; +} + +/** + * Return the secret path only when it is safe to read: absent (caller handles + * null), already private, or self-healable by tightening our own file's mode. + * Refuses a foreign-owned or unhealable file so a world-readable credential is + * never trusted. + */ +function resolveUsableRelaySecretPath(env: NodeJS.ProcessEnv): string | null { + const secretPath = resolveExtensionRelaySecretPath(env); + let stat: fs.Stats; + try { + stat = fs.lstatSync(secretPath); + } catch (err) { + // Absent is the normal "not paired yet" case; let the reader return null. + return (err as NodeJS.ErrnoException).code === "ENOENT" ? secretPath : null; + } + if (stat.isSymbolicLink() || !stat.isFile()) { + log.warn("ignoring extension relay secret: not a regular file"); + return null; + } + const decision = classifyRelaySecretPrivacy(stat, process.getuid?.()); + if (decision === "refuse") { + log.warn("ignoring extension relay secret: owned by another user"); + return null; + } + if (decision === "heal") { + try { + fs.chmodSync(secretPath, PRIVATE_SECRET_FILE_MODE); + log.warn("tightened extension relay secret permissions to 0600"); + } catch { + log.warn("ignoring extension relay secret: permissions are too broad and could not be fixed"); + return null; + } + } + return secretPath; +} + /** Read the host-local relay token, or null when it has not been created yet. */ export function readExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null { - return normalizeToken( - tryReadSecretFileSync(resolveExtensionRelaySecretPath(env), "browser extension relay secret") ?? - "", - ); + const secretPath = resolveUsableRelaySecretPath(env); + if (!secretPath) { + return null; + } + return normalizeToken(tryReadSecretFileSync(secretPath, "browser extension relay secret") ?? ""); } /** @@ -71,16 +139,11 @@ export async function ensureExtensionRelayToken( } // Another process created it first; its exclusive async write may still be // finishing after the final name appears, so adopt it with a bounded reread. + // Reuse the hardened sync read so a foreign-owned file is never adopted here. for (let attempt = 0; attempt < RELAY_SECRET_REREAD_ATTEMPTS; attempt += 1) { - try { - const winner = normalizeToken( - await readSecretFile(secretPath, "browser extension relay secret"), - ); - if (winner) { - return winner; - } - } catch { - // Retry only inside the bounded first-writer handoff window. + const winner = readExtensionRelayToken(env); + if (winner) { + return winner; } await new Promise((resolve) => { setTimeout(resolve, RELAY_SECRET_REREAD_DELAY_MS);