VYPR
Moderate severityNVD Advisory· Published Mar 5, 2026· Updated Mar 11, 2026

OpenClaw 2026.1.14-1 < 2026.2.12 - Unintended Public Binding of Chrome Extension Relay via Wildcard cdpUrl

CVE-2026-28395

Description

OpenClaw version 2026.1.14-1 prior to 2026.2.12 contain an improper network binding vulnerability in the Chrome extension (must be installed and enabled) relay server that treats wildcard hosts as loopback addresses, allowing the relay HTTP/WS server to bind to all interfaces when a wildcard cdpUrl is configured. Remote attackers can access relay HTTP endpoints off-host to leak service presence and port information, or conduct denial-of-service and brute-force attacks against the relay token header.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.1.14-1, < 2026.2.122026.2.12

Affected products

1

Patches

2
8d75a496bf5a

refactor: centralize isPlainObject, isRecord, isErrno, isLoopbackHost utilities (#12926)

37 files changed · +97 226
  • src/agents/cli-runner/helpers.ts+1 5 modified
    @@ -10,7 +10,7 @@ import type { CliBackendConfig } from "../../config/types.js";
     import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
     import { runExec } from "../../process/exec.js";
     import { buildTtsSystemPromptHint } from "../../tts/tts.js";
    -import { escapeRegExp } from "../../utils.js";
    +import { escapeRegExp, isRecord } from "../../utils.js";
     import { resolveDefaultModelForAgent } from "../model-selection.js";
     import { detectRuntimeShell } from "../shell-utils.js";
     import { buildSystemPromptParams } from "../system-prompt-params.js";
    @@ -280,10 +280,6 @@ function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
       return { input, output, cacheRead, cacheWrite, total };
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function collectText(value: unknown): string {
       if (!value) {
         return "";
    
  • src/agents/minimax-vlm.ts+1 4 modified
    @@ -1,3 +1,4 @@
    +import { isRecord } from "../utils.js";
     import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
     
     type MinimaxBaseResp = {
    @@ -30,10 +31,6 @@ function coerceApiHost(params: {
       }
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function pickString(rec: Record<string, unknown>, key: string): string {
       const v = rec[key];
       return typeof v === "string" ? v : "";
    
  • src/agents/models-config.ts+1 4 modified
    @@ -1,6 +1,7 @@
     import fs from "node:fs/promises";
     import path from "node:path";
     import { type OpenClawConfig, loadConfig } from "../config/config.js";
    +import { isRecord } from "../utils.js";
     import { resolveOpenClawAgentDir } from "./agent-paths.js";
     import {
       normalizeProviders,
    @@ -14,10 +15,6 @@ type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
     
     const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
       const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
       const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
    
  • src/agents/pi-tool-definition-adapter.ts+1 4 modified
    @@ -6,6 +6,7 @@ import type {
     import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
     import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
     import { logDebug, logError } from "../logger.js";
    +import { isPlainObject } from "../utils.js";
     import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
     import { normalizeToolName } from "./tool-policy.js";
     import { jsonResult } from "./tools/common.js";
    @@ -32,10 +33,6 @@ type ToolExecuteArgs = ToolDefinition["execute"] extends (...args: infer P) => u
       : ToolExecuteArgsCurrent;
     type ToolExecuteArgsAny = ToolExecuteArgs | ToolExecuteArgsLegacy | ToolExecuteArgsCurrent;
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return typeof value === "object" && value !== null && !Array.isArray(value);
    -}
    -
     function isAbortSignal(value: unknown): value is AbortSignal {
       return typeof value === "object" && value !== null && "aborted" in value;
     }
    
  • src/agents/pi-tools.before-tool-call.ts+1 4 modified
    @@ -1,6 +1,7 @@
     import type { AnyAgentTool } from "./tools/common.js";
     import { createSubsystemLogger } from "../logging/subsystem.js";
     import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
    +import { isPlainObject } from "../utils.js";
     import { normalizeToolName } from "./tool-policy.js";
     
     type HookContext = {
    @@ -12,10 +13,6 @@ type HookOutcome = { blocked: true; reason: string } | { blocked: false; params:
     
     const log = createSubsystemLogger("agents/tools");
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return typeof value === "object" && value !== null && !Array.isArray(value);
    -}
    -
     export async function runBeforeToolCallHook(args: {
       toolName: string;
       params: unknown;
    
  • src/agents/tools/cron-tool.ts+1 5 modified
    @@ -3,7 +3,7 @@ import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
     import { loadConfig } from "../../config/config.js";
     import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
     import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
    -import { truncateUtf16Safe } from "../../utils.js";
    +import { isRecord, truncateUtf16Safe } from "../../utils.js";
     import { resolveSessionAgentId } from "../agent-scope.js";
     import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
     import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
    @@ -157,10 +157,6 @@ async function buildReminderContextLines(params: {
       }
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return typeof value === "object" && value !== null && !Array.isArray(value);
    -}
    -
     function stripThreadSuffixFromSessionKey(sessionKey: string): string {
       const normalized = sessionKey.toLowerCase();
       const idx = normalized.lastIndexOf(":thread:");
    
  • src/browser/cdp.helpers.ts+3 13 modified
    @@ -1,7 +1,10 @@
     import WebSocket from "ws";
    +import { isLoopbackHost } from "../gateway/net.js";
     import { rawDataToString } from "../infra/ws.js";
     import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
     
    +export { isLoopbackHost };
    +
     type CdpResponse = {
       id: number;
       result?: unknown;
    @@ -15,19 +18,6 @@ type Pending = {
     
     export type CdpSendFn = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
     
    -export function isLoopbackHost(host: string) {
    -  const h = host.trim().toLowerCase();
    -  return (
    -    h === "localhost" ||
    -    h === "127.0.0.1" ||
    -    h === "0.0.0.0" ||
    -    h === "[::1]" ||
    -    h === "::1" ||
    -    h === "[::]" ||
    -    h === "::"
    -  );
    -}
    -
     export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
       const relayHeaders = getChromeExtensionRelayAuthHeaders(url);
       const mergedHeaders = { ...relayHeaders, ...headers };
    
  • src/browser/config.ts+1 13 modified
    @@ -5,6 +5,7 @@ import {
       deriveDefaultBrowserControlPort,
       DEFAULT_BROWSER_CONTROL_PORT,
     } from "../config/port-defaults.js";
    +import { isLoopbackHost } from "../gateway/net.js";
     import {
       DEFAULT_OPENCLAW_BROWSER_COLOR,
       DEFAULT_OPENCLAW_BROWSER_ENABLED,
    @@ -42,19 +43,6 @@ export type ResolvedBrowserProfile = {
       driver: "openclaw" | "extension";
     };
     
    -function isLoopbackHost(host: string) {
    -  const h = host.trim().toLowerCase();
    -  return (
    -    h === "localhost" ||
    -    h === "127.0.0.1" ||
    -    h === "0.0.0.0" ||
    -    h === "[::1]" ||
    -    h === "::1" ||
    -    h === "[::]" ||
    -    h === "::"
    -  );
    -}
    -
     function normalizeHexColor(raw: string | undefined) {
       const value = (raw ?? "").trim();
       if (!value) {
    
  • src/browser/extension-relay.ts+1 13 modified
    @@ -4,6 +4,7 @@ import type { Duplex } from "node:stream";
     import { randomBytes } from "node:crypto";
     import { createServer } from "node:http";
     import WebSocket, { WebSocketServer } from "ws";
    +import { isLoopbackHost } from "../gateway/net.js";
     import { rawDataToString } from "../infra/ws.js";
     
     type CdpCommand = {
    @@ -101,19 +102,6 @@ export type ChromeExtensionRelayServer = {
       stop: () => Promise<void>;
     };
     
    -function isLoopbackHost(host: string) {
    -  const h = host.trim().toLowerCase();
    -  return (
    -    h === "localhost" ||
    -    h === "127.0.0.1" ||
    -    h === "0.0.0.0" ||
    -    h === "[::1]" ||
    -    h === "::1" ||
    -    h === "[::]" ||
    -    h === "::"
    -  );
    -}
    -
     function isLoopbackAddress(ip: string | undefined): boolean {
       if (!ip) {
         return false;
    
  • src/channels/plugins/catalog.ts+1 5 modified
    @@ -5,7 +5,7 @@ import type { PluginOrigin } from "../../plugins/types.js";
     import type { ChannelMeta } from "./types.js";
     import { MANIFEST_KEY } from "../../compat/legacy-names.js";
     import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
    -import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
    +import { CONFIG_DIR, isRecord, resolveUserPath } from "../../utils.js";
     
     export type ChannelUiMetaEntry = {
       id: string;
    @@ -61,10 +61,6 @@ const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALO
     
     type ManifestKey = typeof MANIFEST_KEY;
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] {
       if (Array.isArray(raw)) {
         return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry));
    
  • src/channels/plugins/status-issues/shared.ts+3 4 modified
    @@ -1,11 +1,10 @@
    +import { isRecord } from "../../../utils.js";
    +export { isRecord };
    +
     export function asString(value: unknown): string | undefined {
       return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
     }
     
    -export function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    -}
    -
     export function formatMatchMetadata(params: {
       matchKey?: unknown;
       matchSource?: unknown;
    
  • src/commands/doctor-config-flow.ts+1 5 modified
    @@ -12,14 +12,10 @@ import {
     } from "../config/config.js";
     import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
     import { note } from "../terminal/note.js";
    -import { resolveHomeDir } from "../utils.js";
    +import { isRecord, resolveHomeDir } from "../utils.js";
     import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
     import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     type UnrecognizedKeysIssue = ZodIssue & {
       code: "unrecognized_keys";
       keys: PropertyKey[];
    
  • src/config/config-paths.ts+2 9 modified
    @@ -1,3 +1,5 @@
    +import { isPlainObject } from "../utils.js";
    +
     type PathNode = Record<string, unknown>;
     
     const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]);
    @@ -79,12 +81,3 @@ export function getConfigValueAtPath(root: PathNode, path: string[]): unknown {
       }
       return cursor;
     }
    -
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return (
    -    typeof value === "object" &&
    -    value !== null &&
    -    !Array.isArray(value) &&
    -    Object.prototype.toString.call(value) === "[object Object]"
    -  );
    -}
    
  • src/config/env-substitution.ts+2 9 modified
    @@ -22,6 +22,8 @@
     
     // Pattern for valid uppercase env var names: starts with letter or underscore,
     // followed by letters, numbers, or underscores (all uppercase)
    +import { isPlainObject } from "../utils.js";
    +
     const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
     
     export class MissingEnvVarError extends Error {
    @@ -34,15 +36,6 @@ export class MissingEnvVarError extends Error {
       }
     }
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return (
    -    typeof value === "object" &&
    -    value !== null &&
    -    !Array.isArray(value) &&
    -    Object.prototype.toString.call(value) === "[object Object]"
    -  );
    -}
    -
     function substituteString(value: string, env: NodeJS.ProcessEnv, configPath: string): string {
       if (!value.includes("$")) {
         return value;
    
  • src/config/includes.ts+1 9 modified
    @@ -13,6 +13,7 @@
     import JSON5 from "json5";
     import fs from "node:fs";
     import path from "node:path";
    +import { isPlainObject } from "../utils.js";
     
     export const INCLUDE_KEY = "$include";
     export const MAX_INCLUDE_DEPTH = 10;
    @@ -52,15 +53,6 @@ export class CircularIncludeError extends ConfigIncludeError {
     // Utilities
     // ============================================================================
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return (
    -    typeof value === "object" &&
    -    value !== null &&
    -    !Array.isArray(value) &&
    -    Object.prototype.toString.call(value) === "[object Object]"
    -  );
    -}
    -
     /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */
     export function deepMerge(target: unknown, source: unknown): unknown {
       if (Array.isArray(target) && Array.isArray(source)) {
    
  • src/config/legacy.shared.ts+2 2 modified
    @@ -10,8 +10,8 @@ export type LegacyConfigMigration = {
       apply: (raw: Record<string, unknown>, changes: string[]) => void;
     };
     
    -export const isRecord = (value: unknown): value is Record<string, unknown> =>
    -  Boolean(value && typeof value === "object" && !Array.isArray(value));
    +import { isRecord } from "../utils.js";
    +export { isRecord };
     
     export const getRecord = (value: unknown): Record<string, unknown> | null =>
       isRecord(value) ? value : null;
    
  • src/config/merge-patch.ts+2 4 modified
    @@ -1,8 +1,6 @@
    -type PlainObject = Record<string, unknown>;
    +import { isPlainObject } from "../utils.js";
     
    -function isPlainObject(value: unknown): value is PlainObject {
    -  return typeof value === "object" && value !== null && !Array.isArray(value);
    -}
    +type PlainObject = Record<string, unknown>;
     
     export function applyMergePatch(base: unknown, patch: unknown): unknown {
       if (!isPlainObject(patch)) {
    
  • src/config/normalize-paths.ts+1 5 modified
    @@ -1,15 +1,11 @@
     import type { OpenClawConfig } from "./types.js";
    -import { resolveUserPath } from "../utils.js";
    +import { isPlainObject, resolveUserPath } from "../utils.js";
     
     const PATH_VALUE_RE = /^~(?=$|[\\/])/;
     
     const PATH_KEY_RE = /(dir|path|paths|file|root|workspace)$/i;
     const PATH_LIST_KEYS = new Set(["paths", "pathPrepend"]);
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    -}
    -
     function normalizeStringValue(key: string | undefined, value: string): string {
       if (!PATH_VALUE_RE.test(value.trim())) {
         return value;
    
  • src/config/plugin-auto-enable.ts+1 4 modified
    @@ -9,6 +9,7 @@ import {
       listChatChannels,
       normalizeChatChannelId,
     } from "../channels/registry.js";
    +import { isRecord } from "../utils.js";
     import { hasAnyWhatsAppAuth } from "../web/accounts.js";
     
     type PluginEnableChange = {
    @@ -36,10 +37,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
       { pluginId: "minimax-portal-auth", providerId: "minimax-portal" },
     ];
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function hasNonEmptyString(value: unknown): boolean {
       return typeof value === "string" && value.trim().length > 0;
     }
    
  • src/config/runtime-overrides.ts+1 9 modified
    @@ -1,4 +1,5 @@
     import type { OpenClawConfig } from "./types.js";
    +import { isPlainObject } from "../utils.js";
     import { parseConfigPath, setConfigValueAtPath, unsetConfigValueAtPath } from "./config-paths.js";
     
     type OverrideTree = Record<string, unknown>;
    @@ -19,15 +20,6 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
       return next;
     }
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return (
    -    typeof value === "object" &&
    -    value !== null &&
    -    !Array.isArray(value) &&
    -    Object.prototype.toString.call(value) === "[object Object]"
    -  );
    -}
    -
     export function getConfigOverrides(): OverrideTree {
       return overrides;
     }
    
  • src/config/validation.ts+1 4 modified
    @@ -9,6 +9,7 @@ import {
     } from "../plugins/config-state.js";
     import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
     import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
    +import { isRecord } from "../utils.js";
     import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
     import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
     import { findLegacyConfigIssues } from "./legacy.js";
    @@ -129,10 +130,6 @@ export function validateConfigObject(
       };
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     export function validateConfigObjectWithPlugins(raw: unknown):
       | {
           ok: true;
    
  • src/cron/normalize.ts+1 4 modified
    @@ -1,5 +1,6 @@
     import type { CronJobCreate, CronJobPatch } from "./types.js";
     import { sanitizeAgentId } from "../routing/session-key.js";
    +import { isRecord } from "../utils.js";
     import { parseAbsoluteTimeMs } from "./parse.js";
     import { migrateLegacyCronPayload } from "./payload-migration.js";
     import { inferLegacyName } from "./service/normalize.js";
    @@ -14,10 +15,6 @@ const DEFAULT_OPTIONS: NormalizeOptions = {
       applyDefaults: false,
     };
     
    -function isRecord(value: unknown): value is UnknownRecord {
    -  return typeof value === "object" && value !== null && !Array.isArray(value);
    -}
    -
     function coerceSchedule(schedule: UnknownRecord) {
       const next: UnknownRecord = { ...schedule };
       const rawKind = typeof schedule.kind === "string" ? schedule.kind.trim().toLowerCase() : "";
    
  • src/discord/audit.ts+1 4 modified
    @@ -1,5 +1,6 @@
     import type { OpenClawConfig } from "../config/config.js";
     import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js";
    +import { isRecord } from "../utils.js";
     import { resolveDiscordAccount } from "./accounts.js";
     import { fetchChannelPermissionsDiscord } from "./send.js";
     
    @@ -22,10 +23,6 @@ export type DiscordChannelPermissionsAudit = {
     
     const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    -}
    -
     function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
       if (!config) {
         return true;
    
  • src/gateway/config-reload.ts+1 9 modified
    @@ -2,6 +2,7 @@ import chokidar from "chokidar";
     import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
     import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
     import { getActivePluginRegistry } from "../plugins/runtime.js";
    +import { isPlainObject } from "../utils.js";
     
     export type GatewayReloadSettings = {
       mode: GatewayReloadMode;
    @@ -126,15 +127,6 @@ function matchRule(path: string): ReloadRule | null {
       return null;
     }
     
    -function isPlainObject(value: unknown): value is Record<string, unknown> {
    -  return Boolean(
    -    value &&
    -    typeof value === "object" &&
    -    !Array.isArray(value) &&
    -    Object.prototype.toString.call(value) === "[object Object]",
    -  );
    -}
    -
     export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
       if (prev === next) {
         return [];
    
  • src/gateway/net.ts+15 1 modified
    @@ -255,6 +255,20 @@ function isValidIPv4(host: string): boolean {
       });
     }
     
    +/**
    + * Check if a hostname or IP refers to the local machine.
    + * Handles: localhost, 127.x.x.x, ::1, [::1], ::ffff:127.x.x.x
    + * Note: 0.0.0.0 and :: are NOT loopback - they bind to all interfaces.
    + */
     export function isLoopbackHost(host: string): boolean {
    -  return isLoopbackAddress(host);
    +  if (!host) {
    +    return false;
    +  }
    +  const h = host.trim().toLowerCase();
    +  if (h === "localhost") {
    +    return true;
    +  }
    +  // Handle bracketed IPv6 addresses like [::1]
    +  const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
    +  return isLoopbackAddress(unbracket);
     }
    
  • src/gateway/origin-check.ts+2 16 modified
    @@ -1,3 +1,5 @@
    +import { isLoopbackHost } from "./net.js";
    +
     type OriginCheckResult = { ok: true } | { ok: false; reason: string };
     
     function normalizeHostHeader(hostHeader?: string): string {
    @@ -38,22 +40,6 @@ function parseOrigin(
       }
     }
     
    -function isLoopbackHost(hostname: string): boolean {
    -  if (!hostname) {
    -    return false;
    -  }
    -  if (hostname === "localhost") {
    -    return true;
    -  }
    -  if (hostname === "::1") {
    -    return true;
    -  }
    -  if (hostname === "127.0.0.1" || hostname.startsWith("127.")) {
    -    return true;
    -  }
    -  return false;
    -}
    -
     export function checkBrowserOrigin(params: {
       requestHost?: string;
       origin?: string;
    
  • src/infra/canvas-host-url.ts+2 17 modified
    @@ -1,3 +1,5 @@
    +import { isLoopbackHost } from "../gateway/net.js";
    +
     type HostSource = string | null | undefined;
     
     type CanvasHostUrlParams = {
    @@ -9,23 +11,6 @@ type CanvasHostUrlParams = {
       scheme?: "http" | "https";
     };
     
    -const isLoopbackHost = (value: string) => {
    -  const normalized = value.trim().toLowerCase();
    -  if (!normalized) {
    -    return false;
    -  }
    -  if (normalized === "localhost") {
    -    return true;
    -  }
    -  if (normalized === "::1") {
    -    return true;
    -  }
    -  if (normalized === "0.0.0.0" || normalized === "::") {
    -    return true;
    -  }
    -  return normalized.startsWith("127.");
    -};
    -
     const normalizeHost = (value: HostSource, rejectLoopback: boolean) => {
       if (!value) {
         return "";
    
  • src/infra/errors.ts+14 0 modified
    @@ -12,6 +12,20 @@ export function extractErrorCode(err: unknown): string | undefined {
       return undefined;
     }
     
    +/**
    + * Type guard for NodeJS.ErrnoException (any error with a `code` property).
    + */
    +export function isErrno(err: unknown): err is NodeJS.ErrnoException {
    +  return Boolean(err && typeof err === "object" && "code" in err);
    +}
    +
    +/**
    + * Check if an error has a specific errno code.
    + */
    +export function hasErrnoCode(err: unknown, code: string): boolean {
    +  return isErrno(err) && err.code === code;
    +}
    +
     export function formatErrorMessage(err: unknown): string {
       if (err instanceof Error) {
         return err.message || err.name || "Error";
    
  • src/infra/ports-inspect.ts+1 4 modified
    @@ -1,6 +1,7 @@
     import net from "node:net";
     import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
     import { runCommandWithTimeout } from "../process/exec.js";
    +import { isErrno } from "./errors.js";
     import { buildPortHints } from "./ports-format.js";
     import { resolveLsofCommand } from "./ports-lsof.js";
     
    @@ -11,10 +12,6 @@ type CommandResult = {
       error?: string;
     };
     
    -function isErrno(err: unknown): err is NodeJS.ErrnoException {
    -  return Boolean(err && typeof err === "object" && "code" in err);
    -}
    -
     async function runCommandSafe(argv: string[], timeoutMs = 5_000): Promise<CommandResult> {
       try {
         const res = await runCommandWithTimeout(argv, { timeoutMs });
    
  • src/infra/ports.ts+1 4 modified
    @@ -4,6 +4,7 @@ import type { PortListener, PortListenerKind, PortUsage, PortUsageStatus } from
     import { danger, info, shouldLogVerbose, warn } from "../globals.js";
     import { logDebug } from "../logger.js";
     import { defaultRuntime } from "../runtime.js";
    +import { isErrno } from "./errors.js";
     import { formatPortDiagnostics } from "./ports-format.js";
     import { inspectPortUsage } from "./ports-inspect.js";
     
    @@ -19,10 +20,6 @@ class PortInUseError extends Error {
       }
     }
     
    -function isErrno(err: unknown): err is NodeJS.ErrnoException {
    -  return Boolean(err && typeof err === "object" && "code" in err);
    -}
    -
     export async function describePortOwner(port: number): Promise<string | undefined> {
       const diagnostics = await inspectPortUsage(port);
       if (diagnostics.listeners.length === 0) {
    
  • src/infra/provider-usage.fetch.minimax.ts+1 4 modified
    @@ -1,4 +1,5 @@
     import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
    +import { isRecord } from "../utils.js";
     import { fetchJson } from "./provider-usage.fetch.shared.js";
     import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
     
    @@ -148,10 +149,6 @@ const WINDOW_MINUTE_KEYS = [
       "minutes",
     ] as const;
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     function pickNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
       for (const key of keys) {
         const value = record[key];
    
  • src/infra/ssh-tunnel.ts+1 4 modified
    @@ -1,5 +1,6 @@
     import { spawn } from "node:child_process";
     import net from "node:net";
    +import { isErrno } from "./errors.js";
     import { ensurePortAvailable } from "./ports.js";
     
     export type SshParsedTarget = {
    @@ -17,10 +18,6 @@ export type SshTunnel = {
       stop: () => Promise<void>;
     };
     
    -function isErrno(err: unknown): err is NodeJS.ErrnoException {
    -  return Boolean(err && typeof err === "object" && "code" in err);
    -}
    -
     export function parseSshTarget(raw: string): SshParsedTarget | null {
       const trimmed = raw.trim().replace(/^ssh\s+/, "");
       if (!trimmed) {
    
  • src/plugins/manifest.ts+1 4 modified
    @@ -2,6 +2,7 @@ import fs from "node:fs";
     import path from "node:path";
     import type { PluginConfigUiHint, PluginKind } from "./types.js";
     import { MANIFEST_KEY } from "../compat/legacy-names.js";
    +import { isRecord } from "../utils.js";
     
     export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
     export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
    @@ -30,10 +31,6 @@ function normalizeStringList(value: unknown): string[] {
       return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value && typeof value === "object" && !Array.isArray(value));
    -}
    -
     export function resolvePluginManifestPath(rootDir: string): string {
       for (const filename of PLUGIN_MANIFEST_FILENAMES) {
         const candidate = path.join(rootDir, filename);
    
  • src/security/skill-scanner.ts+4 13 modified
    @@ -1,5 +1,6 @@
     import fs from "node:fs/promises";
     import path from "node:path";
    +import { hasErrnoCode } from "../infra/errors.js";
     
     // ---------------------------------------------------------------------------
     // Types
    @@ -52,16 +53,6 @@ export function isScannable(filePath: string): boolean {
       return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
     }
     
    -function isErrno(err: unknown, code: string): boolean {
    -  if (!err || typeof err !== "object") {
    -    return false;
    -  }
    -  if (!("code" in err)) {
    -    return false;
    -  }
    -  return (err as { code?: unknown }).code === code;
    -}
    -
     // ---------------------------------------------------------------------------
     // Rule definitions
     // ---------------------------------------------------------------------------
    @@ -327,7 +318,7 @@ async function resolveForcedFiles(params: {
         try {
           st = await fs.stat(includePath);
         } catch (err) {
    -      if (isErrno(err, "ENOENT")) {
    +      if (hasErrnoCode(err, "ENOENT")) {
             continue;
           }
           throw err;
    @@ -374,7 +365,7 @@ async function readScannableSource(filePath: string, maxFileBytes: number): Prom
       try {
         st = await fs.stat(filePath);
       } catch (err) {
    -    if (isErrno(err, "ENOENT")) {
    +    if (hasErrnoCode(err, "ENOENT")) {
           return null;
         }
         throw err;
    @@ -385,7 +376,7 @@ async function readScannableSource(filePath: string, maxFileBytes: number): Prom
       try {
         return await fs.readFile(filePath, "utf-8");
       } catch (err) {
    -    if (isErrno(err, "ENOENT")) {
    +    if (hasErrnoCode(err, "ENOENT")) {
           return null;
         }
         throw err;
    
  • src/slack/scopes.ts+1 4 modified
    @@ -1,4 +1,5 @@
     import type { WebClient } from "@slack/web-api";
    +import { isRecord } from "../utils.js";
     import { createSlackWebClient } from "./client.js";
     
     export type SlackScopesResult = {
    @@ -10,10 +11,6 @@ export type SlackScopesResult = {
     
     type SlackScopesSource = "auth.scopes" | "apps.permissions.info";
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    -}
    -
     function collectScopes(value: unknown, into: string[]) {
       if (!value) {
         return;
    
  • src/telegram/audit.ts+1 4 modified
    @@ -1,4 +1,5 @@
     import type { TelegramGroupConfig } from "../config/types.js";
    +import { isRecord } from "../utils.js";
     import { makeProxyFetch } from "./proxy.js";
     
     const TELEGRAM_API_BASE = "https://api.telegram.org";
    @@ -38,10 +39,6 @@ async function fetchWithTimeout(
       }
     }
     
    -function isRecord(value: unknown): value is Record<string, unknown> {
    -  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    -}
    -
     export function collectTelegramUnmentionedGroupIds(
       groups: Record<string, TelegramGroupConfig> | undefined,
     ) {
    
  • src/utils.ts+21 0 modified
    @@ -42,6 +42,27 @@ export function safeParseJson<T>(raw: string): T | null {
       }
     }
     
    +/**
    + * Type guard for plain objects (not arrays, null, Date, RegExp, etc.).
    + * Uses Object.prototype.toString for maximum safety.
    + */
    +export function isPlainObject(value: unknown): value is Record<string, unknown> {
    +  return (
    +    typeof value === "object" &&
    +    value !== null &&
    +    !Array.isArray(value) &&
    +    Object.prototype.toString.call(value) === "[object Object]"
    +  );
    +}
    +
    +/**
    + * Type guard for Record<string, unknown> (less strict than isPlainObject).
    + * Accepts any non-null object that isn't an array.
    + */
    +export function isRecord(value: unknown): value is Record<string, unknown> {
    +  return typeof value === "object" && value !== null && !Array.isArray(value);
    +}
    +
     export type WebChannel = "web";
     
     export function assertWebChannel(input: string): asserts input is WebChannel {
    
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

7

News mentions

0

No linked articles in our index yet.