mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(browser): make standalone relay daemon v2-only by default
The daemon previously called startExtensionRelayServer without allowLegacyAuth, inheriting the permissive ?? true default and silently re-enabling legacy one-directional auth (Bearer/Basic/token-subprotocol) even where an operator set extensionRelay.allowLegacyAuth=false. Because legacy auth sends the secret to whoever holds the port, a process that squats the relay port could harvest the credential from a legacy client. Default the standalone daemon to v2-only (mutual HMAC, which the extension and mcporter both speak), honoring an explicit allowLegacyAuth=true opt-in read from the raw config. Adversarial review via subagents surfaced this as the one operator-config regression the PR introduced.
This commit is contained in:
+1
-1
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -4,10 +4,26 @@
|
||||
* Playwright, chrome-devtools-mcp) attach through the same relay port. Spawned
|
||||
* on demand by the native messaging host, or run manually.
|
||||
*/
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { runExtensionRelayDaemon } from "./src/browser/relay-daemon.js";
|
||||
|
||||
const DEFAULT_RELAY_PORT = 18_799;
|
||||
|
||||
/**
|
||||
* The standalone daemon is v2-only by default. Honor an explicit
|
||||
* `browser.extensionRelay.allowLegacyAuth=true` opt-in, but never fall back to
|
||||
* legacy on a read/parse failure — fail closed to the stricter mode. Read the
|
||||
* raw config value (not the resolved `?? true` default) so an unset key stays
|
||||
* v2-only rather than inheriting the gateway's permissive default.
|
||||
*/
|
||||
function resolveAllowLegacyAuth(): boolean {
|
||||
try {
|
||||
return getRuntimeConfig().browser?.extensionRelay?.allowLegacyAuth === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePortArgument(argv: string[]): number {
|
||||
const index = argv.indexOf("--port");
|
||||
const raw = index >= 0 ? argv[index + 1] : undefined;
|
||||
@@ -22,7 +38,10 @@ function resolvePortArgument(argv: string[]): number {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const run = await runExtensionRelayDaemon({ port: resolvePortArgument(process.argv.slice(2)) });
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: resolvePortArgument(process.argv.slice(2)),
|
||||
allowLegacyAuth: resolveAllowLegacyAuth(),
|
||||
});
|
||||
const stop = (): void => run.stop();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { relayTestKey } from "../../chrome-extension/relay-key.test-support.js";
|
||||
import { runExtensionRelayDaemon } from "./relay-daemon.js";
|
||||
|
||||
const TOKEN = relayTestKey(1);
|
||||
|
||||
const tempStateDirs: string[] = [];
|
||||
const savedStateDirEnv = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedStateDirEnv === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = savedStateDirEnv;
|
||||
}
|
||||
await Promise.all(
|
||||
tempStateDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
/** Point readExtensionRelayToken() at an isolated credentials dir holding TOKEN. */
|
||||
async function stageRelaySecret(): Promise<void> {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-daemon-"));
|
||||
tempStateDirs.push(stateDir);
|
||||
const credentialsDir = path.join(stateDir, "credentials");
|
||||
await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(path.join(credentialsDir, "browser-extension-relay.secret"), `${TOKEN}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
}
|
||||
|
||||
describe("runExtensionRelayDaemon", () => {
|
||||
it("refuses to start without a relay credential", async () => {
|
||||
const run = await runExtensionRelayDaemon({ port: 0, readToken: () => null });
|
||||
@@ -51,6 +80,47 @@ describe("runExtensionRelayDaemon", () => {
|
||||
await expect(run.done).resolves.toBe("idle");
|
||||
});
|
||||
|
||||
it("is v2-only by default: rejects a VALID legacy Bearer credential so a squatter cannot harvest the secret", async () => {
|
||||
await stageRelaySecret();
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: 0,
|
||||
idleExitMs: 60_000,
|
||||
pollMs: 60_000,
|
||||
});
|
||||
try {
|
||||
// Even the correct secret over legacy one-directional auth is refused
|
||||
// when allowLegacyAuth is not explicitly enabled.
|
||||
const status = await fetch(`http://127.0.0.1:${run.port}/json/version`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(status.status).toBe(401);
|
||||
} finally {
|
||||
run.stop();
|
||||
await run.done;
|
||||
}
|
||||
});
|
||||
|
||||
it("honors an explicit allowLegacyAuth opt-in", async () => {
|
||||
await stageRelaySecret();
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: 0,
|
||||
allowLegacyAuth: true,
|
||||
idleExitMs: 60_000,
|
||||
pollMs: 60_000,
|
||||
});
|
||||
try {
|
||||
// With legacy auth enabled the credential is accepted; the request then
|
||||
// reaches the "extension not connected" state (503) rather than 401.
|
||||
const status = await fetch(`http://127.0.0.1:${run.port}/json/version`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(status.status).toBe(503);
|
||||
} finally {
|
||||
run.stop();
|
||||
await run.done;
|
||||
}
|
||||
});
|
||||
|
||||
it("stops on demand", async () => {
|
||||
const run = await runExtensionRelayDaemon({
|
||||
port: 0,
|
||||
|
||||
@@ -30,11 +30,20 @@ export type RelayDaemonRun = {
|
||||
export async function runExtensionRelayDaemon(params: {
|
||||
port: number;
|
||||
readToken?: () => string | null;
|
||||
/**
|
||||
* Accept the legacy one-directional relay auth (Bearer/Basic/token
|
||||
* subprotocol). Defaults to false: the standalone daemon is v2-only, so a
|
||||
* process that squats the relay port cannot harvest the secret from a legacy
|
||||
* client, and an operator's `extensionRelay.allowLegacyAuth=false` is never
|
||||
* silently reverted. The extension and mcporter both speak v2.
|
||||
*/
|
||||
allowLegacyAuth?: boolean;
|
||||
idleExitMs?: number;
|
||||
pollMs?: number;
|
||||
now?: () => number;
|
||||
}): Promise<RelayDaemonRun> {
|
||||
const readToken = params.readToken ?? readExtensionRelayToken;
|
||||
const allowLegacyAuth = params.allowLegacyAuth ?? false;
|
||||
const now = params.now ?? Date.now;
|
||||
const idleExitMs = params.idleExitMs ?? RELAY_DAEMON_IDLE_EXIT_MS;
|
||||
const pollMs = params.pollMs ?? IDLE_POLL_MS;
|
||||
@@ -52,7 +61,7 @@ export async function runExtensionRelayDaemon(params: {
|
||||
|
||||
let handle: ExtensionRelayHandle;
|
||||
try {
|
||||
handle = await startExtensionRelayServer({ port: params.port, token });
|
||||
handle = await startExtensionRelayServer({ port: params.port, token, allowLegacyAuth });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE") {
|
||||
log.info(`relay port ${params.port} is already served; standalone daemon not needed`);
|
||||
|
||||
Reference in New Issue
Block a user