VYPR
Moderate severityNVD Advisory· Published Mar 18, 2026· Updated Mar 25, 2026

OpenClaw < 2026.2.22 - Gateway Token Disclosure via Chrome CDP Probe

CVE-2026-22174

Description

OpenClaw versions prior to 2026.2.22 inject the x-OpenClaw-relay-token header into Chrome CDP probe traffic on loopback interfaces, allowing local processes to capture the Gateway authentication token. An attacker controlling a loopback port can intercept CDP reachability probes to the /json/version endpoint and reuse the leaked token as Gateway bearer authentication.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.222026.2.22

Affected products

1

Patches

1
afa22acc4a09

fix: harden extension relay auth token flow

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
4 files changed · +152 52
  • src/browser/browser-utils.test.ts+30 0 modified
    @@ -3,10 +3,15 @@ import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
     import { __test } from "./client-fetch.js";
     import { resolveBrowserConfig, resolveProfile } from "./config.js";
     import { shouldRejectBrowserMutation } from "./csrf.js";
    +import {
    +  ensureChromeExtensionRelayServer,
    +  stopChromeExtensionRelayServer,
    +} from "./extension-relay.js";
     import { toBoolean } from "./routes/utils.js";
     import type { BrowserServerState } from "./server-context.js";
     import { listKnownProfileNames } from "./server-context.js";
     import { resolveTargetIdFromTabs } from "./target-id.js";
    +import { getFreePort } from "./test-port.js";
     
     describe("toBoolean", () => {
       it("parses yes/no and 1/0", () => {
    @@ -161,6 +166,31 @@ describe("cdp.helpers", () => {
         });
         expect(headers.Authorization).toBe("Bearer token");
       });
    +
    +  it("does not add relay header for unknown loopback ports", () => {
    +    const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version");
    +    expect(headers["x-openclaw-relay-token"]).toBeUndefined();
    +  });
    +
    +  it("adds relay header for known relay ports", async () => {
    +    const port = await getFreePort();
    +    const cdpUrl = `http://127.0.0.1:${port}`;
    +    const prev = process.env.OPENCLAW_GATEWAY_TOKEN;
    +    process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
    +    try {
    +      await ensureChromeExtensionRelayServer({ cdpUrl });
    +      const headers = getHeadersWithAuth(`${cdpUrl}/json/version`);
    +      expect(headers["x-openclaw-relay-token"]).toBeTruthy();
    +      expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token");
    +    } finally {
    +      await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
    +      if (prev === undefined) {
    +        delete process.env.OPENCLAW_GATEWAY_TOKEN;
    +      } else {
    +        process.env.OPENCLAW_GATEWAY_TOKEN = prev;
    +      }
    +    }
    +  });
     });
     
     describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
    
  • src/browser/extension-relay-auth.ts+65 0 added
    @@ -0,0 +1,65 @@
    +import { createHmac } from "node:crypto";
    +import { loadConfig } from "../config/config.js";
    +
    +const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
    +const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
    +const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
    +
    +function resolveGatewayAuthToken(): string | null {
    +  const envToken =
    +    process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
    +  if (envToken) {
    +    return envToken;
    +  }
    +  try {
    +    const cfg = loadConfig();
    +    const configToken = cfg.gateway?.auth?.token?.trim();
    +    if (configToken) {
    +      return configToken;
    +    }
    +  } catch {
    +    // ignore config read failures; caller can fallback to per-process random token
    +  }
    +  return null;
    +}
    +
    +function deriveRelayAuthToken(gatewayToken: string, port: number): string {
    +  return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
    +}
    +
    +export function resolveRelayAuthTokenForPort(port: number): string {
    +  const gatewayToken = resolveGatewayAuthToken();
    +  if (gatewayToken) {
    +    return deriveRelayAuthToken(gatewayToken, port);
    +  }
    +  throw new Error(
    +    "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
    +  );
    +}
    +
    +export async function probeAuthenticatedOpenClawRelay(params: {
    +  baseUrl: string;
    +  relayAuthHeader: string;
    +  relayAuthToken: string;
    +  timeoutMs?: number;
    +}): Promise<boolean> {
    +  const ctrl = new AbortController();
    +  const timer = setTimeout(() => ctrl.abort(), params.timeoutMs ?? DEFAULT_RELAY_PROBE_TIMEOUT_MS);
    +  try {
    +    const versionUrl = new URL("/json/version", `${params.baseUrl}/`).toString();
    +    const res = await fetch(versionUrl, {
    +      signal: ctrl.signal,
    +      headers: { [params.relayAuthHeader]: params.relayAuthToken },
    +    });
    +    if (!res.ok) {
    +      return false;
    +    }
    +    const body = (await res.json()) as { Browser?: unknown };
    +    const browserName = typeof body?.Browser === "string" ? body.Browser.trim() : "";
    +    return browserName === OPENCLAW_RELAY_BROWSER;
    +  } catch {
    +    return false;
    +  } finally {
    +    clearTimeout(timer);
    +  }
    +}
    
  • src/browser/extension-relay.test.ts+28 5 modified
    @@ -170,11 +170,17 @@ describe("chrome extension relay server", () => {
         ext.close();
       });
     
    -  it("uses gateway token for relay auth headers on loopback URLs", async () => {
    +  it("uses relay-scoped token only for known relay ports", async () => {
         const port = await getFreePort();
    -    const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
    +    const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
    +    expect(unknown).toEqual({});
    +
    +    cdpUrl = `http://127.0.0.1:${port}`;
    +    await ensureChromeExtensionRelayServer({ cdpUrl });
    +
    +    const headers = getChromeExtensionRelayAuthHeaders(cdpUrl);
         expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
    -    expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN);
    +    expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
       });
     
       it("rejects CDP access without relay auth token", async () => {
    @@ -200,13 +206,15 @@ describe("chrome extension relay server", () => {
         expect(err.message).toContain("401");
       });
     
    -  it("accepts extension websocket access with gateway token query param", async () => {
    +  it("accepts extension websocket access with relay token query param", async () => {
         const port = await getFreePort();
         cdpUrl = `http://127.0.0.1:${port}`;
         await ensureChromeExtensionRelayServer({ cdpUrl });
     
    +    const token = relayAuthHeaders(`ws://127.0.0.1:${port}/extension`)["x-openclaw-relay-token"];
    +    expect(token).toBeTruthy();
         const ext = new WebSocket(
    -      `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
    +      `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(String(token))}`,
         );
         await waitForOpen(ext);
         ext.close();
    @@ -403,7 +411,20 @@ describe("chrome extension relay server", () => {
     
       it("reuses an already-bound relay port when another process owns it", async () => {
         const port = await getFreePort();
    +    let probeToken: string | undefined;
         const fakeRelay = createServer((req, res) => {
    +      if (req.url?.startsWith("/json/version")) {
    +        const header = req.headers["x-openclaw-relay-token"];
    +        probeToken = Array.isArray(header) ? header[0] : header;
    +        if (!probeToken) {
    +          res.writeHead(401);
    +          res.end("Unauthorized");
    +          return;
    +        }
    +        res.writeHead(200, { "Content-Type": "application/json" });
    +        res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
    +        return;
    +      }
           if (req.url?.startsWith("/extension/status")) {
             res.writeHead(200, { "Content-Type": "application/json" });
             res.end(JSON.stringify({ connected: false }));
    @@ -427,6 +448,8 @@ describe("chrome extension relay server", () => {
             connected?: boolean;
           };
           expect(status.connected).toBe(false);
    +      expect(probeToken).toBeTruthy();
    +      expect(probeToken).not.toBe("test-gateway-token");
         } finally {
           if (prev === undefined) {
             delete process.env.OPENCLAW_GATEWAY_TOKEN;
    
  • src/browser/extension-relay.ts+29 47 modified
    @@ -3,9 +3,12 @@ import { createServer } from "node:http";
     import type { AddressInfo } from "node:net";
     import type { Duplex } from "node:stream";
     import WebSocket, { WebSocketServer } from "ws";
    -import { loadConfig } from "../config/config.js";
     import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
     import { rawDataToString } from "../infra/ws.js";
    +import {
    +  probeAuthenticatedOpenClawRelay,
    +  resolveRelayAuthTokenForPort,
    +} from "./extension-relay-auth.js";
     
     type CdpCommand = {
       id: number;
    @@ -155,33 +158,15 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
     }
     
     const serversByPort = new Map<number, ChromeExtensionRelayServer>();
    +const relayAuthTokensByPort = new Map<number, string>();
     
    -function resolveGatewayAuthToken(): string | null {
    -  const envToken =
    -    process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
    -  if (envToken) {
    -    return envToken;
    -  }
    -  try {
    -    const cfg = loadConfig();
    -    const configToken = cfg.gateway?.auth?.token?.trim();
    -    if (configToken) {
    -      return configToken;
    -    }
    -  } catch {
    -    // ignore config read failures; caller can fallback to per-process random token
    -  }
    -  return null;
    -}
    -
    -function resolveRelayAuthToken(): string {
    -  const gatewayToken = resolveGatewayAuthToken();
    -  if (gatewayToken) {
    -    return gatewayToken;
    +function resolveUrlPort(parsed: URL): number | null {
    +  const port =
    +    parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
    +  if (!Number.isFinite(port) || port <= 0 || port > 65535) {
    +    return null;
       }
    -  throw new Error(
    -    "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
    -  );
    +  return port;
     }
     
     function isAddrInUseError(err: unknown): boolean {
    @@ -193,31 +178,17 @@ function isAddrInUseError(err: unknown): boolean {
       );
     }
     
    -async function looksLikeOpenClawRelay(baseUrl: string): Promise<boolean> {
    -  const ctrl = new AbortController();
    -  const timer = setTimeout(() => ctrl.abort(), 500);
    -  try {
    -    const statusUrl = new URL("/extension/status", `${baseUrl}/`).toString();
    -    const res = await fetch(statusUrl, { signal: ctrl.signal });
    -    if (!res.ok) {
    -      return false;
    -    }
    -    const body = (await res.json()) as { connected?: unknown };
    -    return typeof body.connected === "boolean";
    -  } catch {
    -    return false;
    -  } finally {
    -    clearTimeout(timer);
    -  }
    -}
    -
     function relayAuthTokenForUrl(url: string): string | null {
       try {
         const parsed = new URL(url);
         if (!isLoopbackHost(parsed.hostname)) {
           return null;
         }
    -    return resolveGatewayAuthToken();
    +    const port = resolveUrlPort(parsed);
    +    if (!port || !serversByPort.has(port)) {
    +      return null;
    +    }
    +    return relayAuthTokensByPort.get(port) ?? null;
       } catch {
         return null;
       }
    @@ -244,7 +215,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
         return existing;
       }
     
    -  const relayAuthToken = resolveRelayAuthToken();
    +  const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
     
       let extensionWs: WebSocket | null = null;
       const cdpClients = new Set<WebSocket>();
    @@ -771,7 +742,14 @@ export async function ensureChromeExtensionRelayServer(opts: {
           server.once("error", reject);
         });
       } catch (err) {
    -    if (isAddrInUseError(err) && (await looksLikeOpenClawRelay(info.baseUrl))) {
    +    if (
    +      isAddrInUseError(err) &&
    +      (await probeAuthenticatedOpenClawRelay({
    +        baseUrl: info.baseUrl,
    +        relayAuthHeader: RELAY_AUTH_HEADER,
    +        relayAuthToken,
    +      }))
    +    ) {
           const existingRelay: ChromeExtensionRelayServer = {
             host: info.host,
             port: info.port,
    @@ -780,9 +758,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
             extensionConnected: () => false,
             stop: async () => {
               serversByPort.delete(info.port);
    +          relayAuthTokensByPort.delete(info.port);
             },
           };
           serversByPort.set(info.port, existingRelay);
    +      relayAuthTokensByPort.set(info.port, relayAuthToken);
           return existingRelay;
         }
         throw err;
    @@ -801,6 +781,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
         extensionConnected: () => Boolean(extensionWs),
         stop: async () => {
           serversByPort.delete(port);
    +      relayAuthTokensByPort.delete(port);
           try {
             extensionWs?.close(1001, "server stopping");
           } catch {
    @@ -822,6 +803,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
       };
     
       serversByPort.set(port, relay);
    +  relayAuthTokensByPort.set(port, relayAuthToken);
       return relay;
     }
     
    

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

5

News mentions

0

No linked articles in our index yet.