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:
Peter Steinberger
2026-08-23 16:04:37 -07:00
parent 2b24baa24d
commit 82b7fd030c
4 changed files with 102 additions and 4 deletions
+1 -1
View File
@@ -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.
+20 -1
View File
@@ -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,
+10 -1
View File
@@ -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`);