mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 03:04:29 -04:00
fix(browser): unify extension relay auth on gateway token
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
ensureChromeExtensionRelayServer,
|
||||
@@ -122,13 +122,25 @@ async function waitForListMatch<T>(
|
||||
}
|
||||
|
||||
describe("chrome extension relay server", () => {
|
||||
const TEST_GATEWAY_TOKEN = "test-gateway-token";
|
||||
let cdpUrl = "";
|
||||
let previousGatewayToken: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (cdpUrl) {
|
||||
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
|
||||
cdpUrl = "";
|
||||
}
|
||||
if (previousGatewayToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises CDP WS only when extension is connected", async () => {
|
||||
@@ -143,7 +155,9 @@ describe("chrome extension relay server", () => {
|
||||
};
|
||||
expect(v1.webSocketDebuggerUrl).toBeUndefined();
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
|
||||
const v2 = (await fetch(`${cdpUrl}/json/version`, {
|
||||
@@ -156,21 +170,11 @@ describe("chrome extension relay server", () => {
|
||||
ext.close();
|
||||
});
|
||||
|
||||
it("derives relay auth headers from gateway token for loopback URLs", async () => {
|
||||
it("uses gateway token for relay auth headers on loopback URLs", async () => {
|
||||
const port = await getFreePort();
|
||||
const prev = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
|
||||
try {
|
||||
const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
||||
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
||||
expect((headers["x-openclaw-relay-token"] ?? "").length).toBeGreaterThan(20);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prev;
|
||||
}
|
||||
}
|
||||
const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
||||
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
||||
expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN);
|
||||
});
|
||||
|
||||
it("rejects CDP access without relay auth token", async () => {
|
||||
@@ -186,12 +190,36 @@ describe("chrome extension relay server", () => {
|
||||
expect(err.message).toContain("401");
|
||||
});
|
||||
|
||||
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
|
||||
it("rejects extension websocket access without relay auth token", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||
const err = await waitForError(ext);
|
||||
expect(err.message).toContain("401");
|
||||
});
|
||||
|
||||
it("accepts extension websocket access with gateway token query param", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(
|
||||
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
|
||||
);
|
||||
await waitForOpen(ext);
|
||||
ext.close();
|
||||
});
|
||||
|
||||
it("tracks attached page targets and exposes them via CDP + /json/list", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
|
||||
// Simulate a tab attach coming from the extension.
|
||||
@@ -307,7 +335,9 @@ describe("chrome extension relay server", () => {
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
|
||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
|
||||
});
|
||||
await waitForOpen(ext);
|
||||
|
||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { createServer } from "node:http";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
|
||||
@@ -94,6 +93,18 @@ function getHeader(req: IncomingMessage, name: string): string | undefined {
|
||||
return headerValue(req.headers[name.toLowerCase()]);
|
||||
}
|
||||
|
||||
function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined {
|
||||
const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim();
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
const queryToken = url?.searchParams.get("token")?.trim();
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ChromeExtensionRelayServer = {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -144,7 +155,6 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
|
||||
}
|
||||
|
||||
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
||||
const relayAuthByPort = new Map<number, string>();
|
||||
|
||||
function resolveGatewayAuthToken(): string | null {
|
||||
const envToken =
|
||||
@@ -164,19 +174,14 @@ function resolveGatewayAuthToken(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveDeterministicRelayAuthToken(port: number): string | null {
|
||||
function resolveRelayAuthToken(): string {
|
||||
const gatewayToken = resolveGatewayAuthToken();
|
||||
if (!gatewayToken) {
|
||||
return null;
|
||||
if (gatewayToken) {
|
||||
return gatewayToken;
|
||||
}
|
||||
return createHash("sha256")
|
||||
.update(`openclaw-relay:${port}:`)
|
||||
.update(gatewayToken)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function resolveRelayAuthToken(port: number): string {
|
||||
return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url");
|
||||
throw new Error(
|
||||
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
|
||||
);
|
||||
}
|
||||
|
||||
function isAddrInUseError(err: unknown): boolean {
|
||||
@@ -212,16 +217,7 @@ function relayAuthTokenForUrl(url: string): string | null {
|
||||
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) ?? deriveDeterministicRelayAuthToken(port);
|
||||
return resolveGatewayAuthToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -248,7 +244,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const relayAuthToken = resolveRelayAuthToken(info.port);
|
||||
const relayAuthToken = resolveRelayAuthToken();
|
||||
|
||||
let extensionWs: WebSocket | null = null;
|
||||
const cdpClients = new Set<WebSocket>();
|
||||
@@ -529,6 +525,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
}
|
||||
|
||||
if (pathname === "/extension") {
|
||||
const token = getRelayAuthTokenFromRequest(req, url);
|
||||
if (!token || token !== relayAuthToken) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
if (extensionWs) {
|
||||
rejectUpgrade(socket, 409, "Extension already connected");
|
||||
return;
|
||||
@@ -540,7 +541,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
}
|
||||
|
||||
if (pathname === "/cdp") {
|
||||
const token = getHeader(req, RELAY_AUTH_HEADER);
|
||||
const token = getRelayAuthTokenFromRequest(req, url);
|
||||
if (!token || token !== relayAuthToken) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized");
|
||||
return;
|
||||
@@ -779,10 +780,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
extensionConnected: () => false,
|
||||
stop: async () => {
|
||||
serversByPort.delete(info.port);
|
||||
relayAuthByPort.delete(info.port);
|
||||
},
|
||||
};
|
||||
relayAuthByPort.set(info.port, relayAuthToken);
|
||||
serversByPort.set(info.port, existingRelay);
|
||||
return existingRelay;
|
||||
}
|
||||
@@ -802,7 +801,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
extensionConnected: () => Boolean(extensionWs),
|
||||
stop: async () => {
|
||||
serversByPort.delete(port);
|
||||
relayAuthByPort.delete(port);
|
||||
try {
|
||||
extensionWs?.close(1001, "server stopping");
|
||||
} catch {
|
||||
@@ -823,7 +821,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
},
|
||||
};
|
||||
|
||||
relayAuthByPort.set(port, relayAuthToken);
|
||||
serversByPort.set(port, relay);
|
||||
return relay;
|
||||
}
|
||||
@@ -835,6 +832,5 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }):
|
||||
return false;
|
||||
}
|
||||
await existing.stop();
|
||||
relayAuthByPort.delete(info.port);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user