VYPR
High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw 2026.1.20 < 2026.2.1 - Missing Authentication in Browser Relay /cdp WebSocket Endpoint

CVE-2026-28458

Description

OpenClaw version 2026.1.20 prior to 2026.2.1 contains a vulnerability in the Browser Relay (extension must be installed and enabled) /cdp WebSocket endpoint in which it does not require authentication tokens, allowing websites to connect via loopback and access sensitive data. Attackers can exploit this by connecting to ws://127.0.0.1:18792/cdp to steal session cookies and execute JavaScript in other browser tabs.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.1.20, < 2026.2.12026.2.1
moltbotnpm
<= 0.1.0

Affected products

1

Patches

1
a1e89afcc19e

fix: secure chrome extension relay cdp

https://github.com/openclaw/openclawPeter SteinbergerFeb 1, 2026via ghsa
6 files changed · +129 11
  • docs/gateway/security/index.md+1 0 modified
    @@ -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).
     - 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.
    +- 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"`).
     - 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.
     
    
  • docs/tools/chrome-extension.md+1 0 modified
    @@ -169,6 +169,7 @@ Recommendations:
     - 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.
     - 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:
     
    
  • src/browser/cdp.helpers.ts+9 4 modified
    @@ -1,5 +1,6 @@
     import WebSocket from "ws";
     import { rawDataToString } from "../infra/ws.js";
    +import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
     
     type CdpResponse = {
       id: number;
    @@ -28,20 +29,24 @@ export function isLoopbackHost(host: string) {
     }
     
     export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
    +  const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
    +  const mergedHeaders = { ...relayHeaders, ...headers };
       try {
         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) {
    -      return headers;
    +      return mergedHeaders;
         }
         if (parsed.username || parsed.password) {
           const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
    -      return { ...headers, Authorization: `Basic ${auth}` };
    +      return { ...mergedHeaders, Authorization: `Basic ${auth}` };
         }
       } catch {
         // ignore
       }
    -  return headers;
    +  return mergedHeaders;
     }
     
     export function appendCdpPath(cdpUrl: string, path: string): string {
    
  • src/browser/extension-relay.test.ts+43 6 modified
    @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
     import WebSocket from "ws";
     import {
       ensureChromeExtensionRelayServer,
    +  getChromeExtensionRelayAuthHeaders,
       stopChromeExtensionRelayServer,
     } 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) {
       const queue: string[] = [];
       let waiter: ((value: string) => void) | null = null;
    @@ -137,22 +149,39 @@ describe("chrome extension relay server", () => {
         cdpUrl = `http://127.0.0.1:${port}`;
         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;
         };
         expect(v1.webSocketDebuggerUrl).toBeUndefined();
     
         const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
         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;
         };
         expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);
     
         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 () => {
         const port = await getFreePort();
         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;
           url?: string;
           title?: string;
    @@ -208,7 +239,9 @@ describe("chrome extension relay server", () => {
     
         const list2 = await waitForListMatch(
           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;
               url?: string;
               title?: string;
    @@ -226,7 +259,9 @@ describe("chrome extension relay server", () => {
           ),
         ).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);
         const q = createMessageQueue(cdp);
     
    @@ -271,7 +306,9 @@ describe("chrome extension relay server", () => {
         const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
         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);
         const q = createMessageQueue(cdp);
     
    
  • src/browser/extension-relay.ts+73 0 modified
    @@ -1,5 +1,7 @@
    +import type { IncomingMessage } from "node:http";
     import type { AddressInfo } from "node:net";
     import type { Duplex } from "node:stream";
    +import { randomBytes } from "node:crypto";
     import { createServer } from "node:http";
     import WebSocket, { WebSocketServer } from "ws";
     import { rawDataToString } from "../infra/ws.js";
    @@ -74,6 +76,22 @@ type ConnectedTarget = {
       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 = {
       host: string;
       port: number;
    @@ -156,6 +174,36 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
     }
     
     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: {
       cdpUrl: string;
    @@ -309,10 +357,21 @@ export async function ensureChromeExtensionRelayServer(opts: {
         }
       };
     
    +  const relayAuthToken = randomBytes(32).toString("base64url");
    +
       const server = createServer((req, res) => {
         const url = new URL(req.url ?? "/", info.baseUrl);
         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 === "/") {
           res.writeHead(200);
           res.end();
    @@ -433,6 +492,12 @@ export async function ensureChromeExtensionRelayServer(opts: {
           return;
         }
     
    +    const origin = headerValue(req.headers.origin);
    +    if (origin && !origin.startsWith("chrome-extension://")) {
    +      rejectUpgrade(socket, 403, "Forbidden: invalid origin");
    +      return;
    +    }
    +
         if (pathname === "/extension") {
           if (extensionWs) {
             rejectUpgrade(socket, 409, "Extension already connected");
    @@ -445,6 +510,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
         }
     
         if (pathname === "/cdp") {
    +      const token = getHeader(req, RELAY_AUTH_HEADER);
    +      if (!token || token !== relayAuthToken) {
    +        rejectUpgrade(socket, 401, "Unauthorized");
    +        return;
    +      }
           if (!extensionWs) {
             rejectUpgrade(socket, 503, "Extension not connected");
             return;
    @@ -682,6 +752,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
         extensionConnected: () => Boolean(extensionWs),
         stop: async () => {
           serversByPort.delete(port);
    +      relayAuthByPort.delete(port);
           try {
             extensionWs?.close(1001, "server stopping");
           } catch {
    @@ -702,6 +773,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
         },
       };
     
    +  relayAuthByPort.set(port, relayAuthToken);
       serversByPort.set(port, relay);
       return relay;
     }
    @@ -713,5 +785,6 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }):
         return false;
       }
       await existing.stop();
    +  relayAuthByPort.delete(info.port);
       return true;
     }
    
  • src/browser/pw-session.ts+2 1 modified
    @@ -400,7 +400,8 @@ async function findPageByTargetId(
             .replace(/\/+$/, "")
             .replace(/^ws:/, "http:")
             .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) {
             const targets = (await response.json()) as Array<{
               id: string;
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.