fix: secure chrome extension relay cdp
This commit is contained in:
parent
e4f7155369
commit
a1e89afcc1
6 changed files with 129 additions and 11 deletions
|
|
@ -610,6 +610,7 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||||
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
|
||||||
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
|
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
|
||||||
|
- The Chrome extension relay’s CDP endpoint is auth-gated; only OpenClaw clients can connect.
|
||||||
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
|
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
|
||||||
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ Recommendations:
|
||||||
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
||||||
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
|
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
|
||||||
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
|
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
|
||||||
|
- The relay blocks non-extension origins and requires an internal auth token for CDP clients.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
|
||||||
|
|
||||||
type CdpResponse = {
|
type CdpResponse = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -28,20 +29,24 @@ export function isLoopbackHost(host: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
||||||
|
const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
|
||||||
|
const mergedHeaders = { ...relayHeaders, ...headers };
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
|
const hasAuthHeader = Object.keys(mergedHeaders).some(
|
||||||
|
(key) => key.toLowerCase() === "authorization",
|
||||||
|
);
|
||||||
if (hasAuthHeader) {
|
if (hasAuthHeader) {
|
||||||
return headers;
|
return mergedHeaders;
|
||||||
}
|
}
|
||||||
if (parsed.username || parsed.password) {
|
if (parsed.username || parsed.password) {
|
||||||
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||||||
return { ...headers, Authorization: `Basic ${auth}` };
|
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return headers;
|
return mergedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendCdpPath(cdpUrl: string, path: string): string {
|
export function appendCdpPath(cdpUrl: string, path: string): string {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import {
|
import {
|
||||||
ensureChromeExtensionRelayServer,
|
ensureChromeExtensionRelayServer,
|
||||||
|
getChromeExtensionRelayAuthHeaders,
|
||||||
stopChromeExtensionRelayServer,
|
stopChromeExtensionRelayServer,
|
||||||
} from "./extension-relay.js";
|
} from "./extension-relay.js";
|
||||||
|
|
||||||
|
|
@ -30,6 +31,17 @@ function waitForOpen(ws: WebSocket) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForError(ws: WebSocket) {
|
||||||
|
return new Promise<Error>((resolve, reject) => {
|
||||||
|
ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
|
||||||
|
ws.once("open", () => reject(new Error("expected websocket error")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayAuthHeaders(url: string) {
|
||||||
|
return getChromeExtensionRelayAuthHeaders(url);
|
||||||
|
}
|
||||||
|
|
||||||
function createMessageQueue(ws: WebSocket) {
|
function createMessageQueue(ws: WebSocket) {
|
||||||
const queue: string[] = [];
|
const queue: string[] = [];
|
||||||
let waiter: ((value: string) => void) | null = null;
|
let waiter: ((value: string) => void) | null = null;
|
||||||
|
|
@ -137,7 +149,9 @@ describe("chrome extension relay server", () => {
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||||
|
|
||||||
const v1 = (await fetch(`${cdpUrl}/json/version`).then((r) => r.json())) as {
|
const v1 = (await fetch(`${cdpUrl}/json/version`, {
|
||||||
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
|
}).then((r) => r.json())) as {
|
||||||
webSocketDebuggerUrl?: string;
|
webSocketDebuggerUrl?: string;
|
||||||
};
|
};
|
||||||
expect(v1.webSocketDebuggerUrl).toBeUndefined();
|
expect(v1.webSocketDebuggerUrl).toBeUndefined();
|
||||||
|
|
@ -145,7 +159,9 @@ describe("chrome extension relay server", () => {
|
||||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||||
await waitForOpen(ext);
|
await waitForOpen(ext);
|
||||||
|
|
||||||
const v2 = (await fetch(`${cdpUrl}/json/version`).then((r) => r.json())) as {
|
const v2 = (await fetch(`${cdpUrl}/json/version`, {
|
||||||
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
|
}).then((r) => r.json())) as {
|
||||||
webSocketDebuggerUrl?: string;
|
webSocketDebuggerUrl?: string;
|
||||||
};
|
};
|
||||||
expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
|
expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
|
||||||
|
|
@ -153,6 +169,19 @@ describe("chrome extension relay server", () => {
|
||||||
ext.close();
|
ext.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects CDP access without relay auth token", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||||
|
|
||||||
|
const res = await fetch(`${cdpUrl}/json/version`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
|
||||||
|
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
||||||
|
const err = await waitForError(cdp);
|
||||||
|
expect(err.message).toContain("401");
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
|
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
|
|
@ -181,7 +210,9 @@ describe("chrome extension relay server", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list = (await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
|
const list = (await fetch(`${cdpUrl}/json/list`, {
|
||||||
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
|
}).then((r) => r.json())) as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -208,7 +239,9 @@ describe("chrome extension relay server", () => {
|
||||||
|
|
||||||
const list2 = await waitForListMatch(
|
const list2 = await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
|
}).then((r) => r.json())) as Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -226,7 +259,9 @@ describe("chrome extension relay server", () => {
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||||
|
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
||||||
|
});
|
||||||
await waitForOpen(cdp);
|
await waitForOpen(cdp);
|
||||||
const q = createMessageQueue(cdp);
|
const q = createMessageQueue(cdp);
|
||||||
|
|
||||||
|
|
@ -271,7 +306,9 @@ describe("chrome extension relay server", () => {
|
||||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||||
await waitForOpen(ext);
|
await waitForOpen(ext);
|
||||||
|
|
||||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||||
|
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
||||||
|
});
|
||||||
await waitForOpen(cdp);
|
await waitForOpen(cdp);
|
||||||
const q = createMessageQueue(cdp);
|
const q = createMessageQueue(cdp);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import WebSocket, { WebSocketServer } from "ws";
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
|
@ -74,6 +76,22 @@ type ConnectedTarget = {
|
||||||
targetInfo: TargetInfo;
|
targetInfo: TargetInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RELAY_AUTH_HEADER = "x-openclaw-relay-token";
|
||||||
|
|
||||||
|
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeader(req: IncomingMessage, name: string): string | undefined {
|
||||||
|
return headerValue(req.headers[name.toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
export type ChromeExtensionRelayServer = {
|
export type ChromeExtensionRelayServer = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|
@ -156,6 +174,36 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
||||||
|
const relayAuthByPort = new Map<number, string>();
|
||||||
|
|
||||||
|
function relayAuthTokenForUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!isLoopbackHost(parsed.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const port =
|
||||||
|
parsed.port?.trim() !== ""
|
||||||
|
? Number(parsed.port)
|
||||||
|
: parsed.protocol === "https:" || parsed.protocol === "wss:"
|
||||||
|
? 443
|
||||||
|
: 80;
|
||||||
|
if (!Number.isFinite(port)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return relayAuthByPort.get(port) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChromeExtensionRelayAuthHeaders(url: string): Record<string, string> {
|
||||||
|
const token = relayAuthTokenForUrl(url);
|
||||||
|
if (!token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { [RELAY_AUTH_HEADER]: token };
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureChromeExtensionRelayServer(opts: {
|
export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
|
|
@ -309,10 +357,21 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const relayAuthToken = randomBytes(32).toString("base64url");
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = new URL(req.url ?? "/", info.baseUrl);
|
const url = new URL(req.url ?? "/", info.baseUrl);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path.startsWith("/json")) {
|
||||||
|
const token = getHeader(req, RELAY_AUTH_HEADER);
|
||||||
|
if (!token || token !== relayAuthToken) {
|
||||||
|
res.writeHead(401);
|
||||||
|
res.end("Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "HEAD" && path === "/") {
|
if (req.method === "HEAD" && path === "/") {
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -433,6 +492,12 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const origin = headerValue(req.headers.origin);
|
||||||
|
if (origin && !origin.startsWith("chrome-extension://")) {
|
||||||
|
rejectUpgrade(socket, 403, "Forbidden: invalid origin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/extension") {
|
if (pathname === "/extension") {
|
||||||
if (extensionWs) {
|
if (extensionWs) {
|
||||||
rejectUpgrade(socket, 409, "Extension already connected");
|
rejectUpgrade(socket, 409, "Extension already connected");
|
||||||
|
|
@ -445,6 +510,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/cdp") {
|
if (pathname === "/cdp") {
|
||||||
|
const token = getHeader(req, RELAY_AUTH_HEADER);
|
||||||
|
if (!token || token !== relayAuthToken) {
|
||||||
|
rejectUpgrade(socket, 401, "Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!extensionWs) {
|
if (!extensionWs) {
|
||||||
rejectUpgrade(socket, 503, "Extension not connected");
|
rejectUpgrade(socket, 503, "Extension not connected");
|
||||||
return;
|
return;
|
||||||
|
|
@ -682,6 +752,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
extensionConnected: () => Boolean(extensionWs),
|
extensionConnected: () => Boolean(extensionWs),
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
serversByPort.delete(port);
|
serversByPort.delete(port);
|
||||||
|
relayAuthByPort.delete(port);
|
||||||
try {
|
try {
|
||||||
extensionWs?.close(1001, "server stopping");
|
extensionWs?.close(1001, "server stopping");
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -702,6 +773,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
relayAuthByPort.set(port, relayAuthToken);
|
||||||
serversByPort.set(port, relay);
|
serversByPort.set(port, relay);
|
||||||
return relay;
|
return relay;
|
||||||
}
|
}
|
||||||
|
|
@ -713,5 +785,6 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }):
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await existing.stop();
|
await existing.stop();
|
||||||
|
relayAuthByPort.delete(info.port);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -400,7 +400,8 @@ async function findPageByTargetId(
|
||||||
.replace(/\/+$/, "")
|
.replace(/\/+$/, "")
|
||||||
.replace(/^ws:/, "http:")
|
.replace(/^ws:/, "http:")
|
||||||
.replace(/\/cdp$/, "");
|
.replace(/\/cdp$/, "");
|
||||||
const response = await fetch(`${baseUrl}/json/list`);
|
const listUrl = `${baseUrl}/json/list`;
|
||||||
|
const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const targets = (await response.json()) as Array<{
|
const targets = (await response.json()) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue