fix(browser): enforce owner + 0600 on relay secret reads

The relay secret is the entire auth model and loopback is not a trust
boundary on a multi-user host, but the fs-safe reader only rejected
symlinks/hardlinks — a secret whose mode drifted group/other-readable
(loosened umask, restore, shared home) was still trusted. Re-check owner
and mode on every read at the relay boundary: self-heal a too-broad mode
on our own file back to 0600, refuse a foreign-owned or non-regular
file, and route the concurrent-creation reread through the same hardened
path so a foreign secret is never adopted. Verified live: a 0644 secret
is tightened to 0600 on read while v2 control keeps working.
This commit is contained in:
Peter Steinberger
2026-08-23 22:03:19 -07:00
parent 82b7fd030c
commit 461e799646
3 changed files with 128 additions and 19 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. 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.
@@ -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");
});
});
@@ -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<void>((resolve) => {
setTimeout(resolve, RELAY_SECRET_REREAD_DELAY_MS);