mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
+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. 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);
|
||||
|
||||
Reference in New Issue
Block a user