From 718dffd2f2eacd9a168dfe61649f495111d428ce Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 17:51:12 -0700 Subject: [PATCH] fix(diagnostics): harden capture redaction and discord metadata fetch (#71303) --- .../diagnostics-otel/src/service.test.ts | 3 +- extensions/diagnostics-otel/src/service.ts | 2 +- .../discord/src/monitor/gateway-plugin.ts | 26 +++----- .../src/monitor/provider.proxy.test.ts | 40 ++----------- src/proxy-capture/runtime.test.ts | 44 +++++++++++++- src/proxy-capture/runtime.ts | 59 ++++++++++++++++--- 6 files changed, 111 insertions(+), 63 deletions(-) diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 348c99a25b4..0cda7fe9df3 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -814,7 +814,7 @@ describe("diagnostics-otel service", () => { toolCallId: "tool-1", durationMs: 20, toolInput: "tool input", - toolOutput: "x".repeat(6000), + toolOutput: `${"x".repeat(4077)} Bearer ${"a".repeat(80)}`, // pragma: allowlist secret } as Parameters[0]); await flushDiagnosticEvents(); @@ -842,6 +842,7 @@ describe("diagnostics-otel service", () => { expect(String(toolAttrs?.["openclaw.content.tool_output"]).length).toBeLessThanOrEqual( MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS + OTEL_TRUNCATED_SUFFIX_MAX_CHARS, ); + expect(String(toolAttrs?.["openclaw.content.tool_output"])).not.toContain("a".repeat(11)); await service.stop?.(ctx); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index ededdeec184..876e2388977 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -134,7 +134,7 @@ function clampOtelLogText(value: string, maxChars: number): string { } function normalizeOtelLogString(value: string, maxChars: number): string { - return redactSensitiveText(clampOtelLogText(value, maxChars)); + return clampOtelLogText(redactSensitiveText(value), maxChars); } function resolveContentCapturePolicy(value: unknown): OtelContentCapturePolicy { diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 1f09c1ad3fe..2fb36ab3019 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -14,7 +14,6 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import * as undici from "undici"; import * as ws from "ws"; import { validateDiscordProxyUrl } from "../proxy-fetch.js"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; @@ -473,8 +472,6 @@ export function createDiscordGatewayPlugin(params: { runtime: RuntimeEnv; __testing?: { HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent; - ProxyAgentCtor?: typeof undici.ProxyAgent; - undiciFetch?: typeof undici.fetch; webSocketCtor?: DiscordGatewayWebSocketCtor; registerClient?: ( plugin: carbonGateway.GatewayPlugin, @@ -520,31 +517,24 @@ export function createDiscordGatewayPlugin(params: { validateDiscordProxyUrl(proxy); const HttpsProxyAgentCtor = params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; - const ProxyAgentCtor = params.__testing?.ProxyAgentCtor ?? undici.ProxyAgent; const wsAgent = new HttpsProxyAgentCtor(proxy); - const fetchAgent = new ProxyAgentCtor(proxy); params.runtime.log?.("discord: gateway proxy enabled"); return createGatewayPlugin({ options, fetchImpl: async (input, init) => { - const response = (await (params.__testing?.undiciFetch ?? undici.fetch)( + return await fetchDiscordGatewayMetadataDirect( input, init, - )) as unknown as Response; - captureHttpExchange({ - url: input, - method: (init?.method as string | undefined) ?? "GET", - requestHeaders: init?.headers as Headers | Record | undefined, - requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, - response, - flowId: randomUUID(), - meta: { subsystem: "discord-gateway-metadata" }, - }); - return response; + debugProxySettings.enabled + ? false + : { + flowId: randomUUID(), + meta: { subsystem: "discord-gateway-metadata" }, + }, + ); }, - fetchInit: { dispatcher: fetchAgent }, wsAgent, runtime: params.runtime, testing: params.__testing diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 2fd1f11c49a..08dc8479a28 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -18,18 +18,12 @@ const { globalFetchMock, HttpsProxyAgent, getLastAgent, - restProxyAgentSpy, resolveDebugProxySettingsMock, - undiciFetchMock, - undiciProxyAgentSpy, resetLastAgent, webSocketSpy, wsProxyAgentSpy, } = vi.hoisted(() => { const wsProxyAgentSpy = vi.fn(); - const undiciProxyAgentSpy = vi.fn(); - const restProxyAgentSpy = vi.fn(); - const undiciFetchMock = vi.fn(); const globalFetchMock = vi.fn(); const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); @@ -86,12 +80,9 @@ const { globalFetchMock, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, - restProxyAgentSpy, captureHttpExchangeSpy, captureWsEventSpy, resolveDebugProxySettingsMock, - undiciFetchMock, - undiciProxyAgentSpy, resetLastAgent: () => { HttpsProxyAgent.lastCreated = undefined; }, @@ -115,15 +106,6 @@ vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); -vi.mock("undici", () => ({ - ProxyAgent: function ProxyAgent(this: { proxyUrl: string }, proxyUrl: string) { - this.proxyUrl = proxyUrl; - undiciProxyAgentSpy(proxyUrl); - restProxyAgentSpy(proxyUrl); - }, - fetch: undiciFetchMock, -})); - vi.mock("ws", () => ({ default: function MockWebSocket(url: string, options?: { agent?: unknown }) { webSocketSpy(url, options); @@ -176,12 +158,6 @@ describe("createDiscordGatewayPlugin", () => { return { HttpsProxyAgentCtor: HttpsProxyAgent as unknown as typeof import("https-proxy-agent").HttpsProxyAgent, - ProxyAgentCtor: function ProxyAgentCtor(this: { proxyUrl: string }, proxyUrl: string) { - this.proxyUrl = proxyUrl; - undiciProxyAgentSpy(proxyUrl); - restProxyAgentSpy(proxyUrl); - } as unknown as typeof import("undici").ProxyAgent, - undiciFetch: undiciFetchMock, webSocketCtor: function WebSocketCtor(url: string, options?: { agent?: unknown }) { webSocketSpy(url, options); } as unknown as new (url: string, options?: { agent?: unknown }) => import("ws").WebSocket, @@ -276,9 +252,6 @@ describe("createDiscordGatewayPlugin", () => { vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); - restProxyAgentSpy.mockClear(); - undiciFetchMock.mockClear(); - undiciProxyAgentSpy.mockClear(); wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); captureHttpExchangeSpy.mockClear(); @@ -454,7 +427,7 @@ describe("createDiscordGatewayPlugin", () => { expect(runtime.log).not.toHaveBeenCalled(); }); - it("uses proxy fetch for gateway metadata lookup before registering", async () => { + it("keeps gateway metadata lookup on the guarded direct fetch when proxy is configured", async () => { const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://127.0.0.1:8080" }, @@ -462,14 +435,12 @@ describe("createDiscordGatewayPlugin", () => { __testing: createProxyTestingOverrides(), }); - await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock }); - expect(restProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); - expect(undiciFetchMock).toHaveBeenCalledWith( + expect(globalFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/gateway/bot", expect.objectContaining({ headers: { Authorization: "Bot token-123" }, - dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }), }), ); expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); @@ -488,7 +459,7 @@ describe("createDiscordGatewayPlugin", () => { expect(captureHttpExchangeSpy).not.toHaveBeenCalled(); }); - it("accepts IPv6 loopback proxy URLs for gateway metadata and websocket setup", async () => { + it("accepts IPv6 loopback proxy URLs for websocket setup", async () => { const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://[::1]:8080" }, @@ -499,10 +470,9 @@ describe("createDiscordGatewayPlugin", () => { const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown }) .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock }); expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); - expect(restProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); expect(runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index bb6247341e1..50cad7bc2cc 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { finalizeDebugProxyCapture, initializeDebugProxyCapture } from "./runtime.js"; +import { + captureHttpExchange, + finalizeDebugProxyCapture, + initializeDebugProxyCapture, +} from "./runtime.js"; const storeState = vi.hoisted(() => { const events: Record[] = []; @@ -82,4 +86,42 @@ describe("debug proxy runtime", () => { expect(events.some((event) => event.kind === "request")).toBe(true); expect(events.some((event) => event.kind === "response")).toBe(true); }); + + it("redacts sensitive request and response headers before persistence", async () => { + initializeDebugProxyCapture("test"); + captureHttpExchange({ + url: "https://discord.com/api/v10/gateway/bot", + method: "GET", + requestHeaders: { + Authorization: "Bot discord-token", + Cookie: "sid=session-token", + "x-api-key": "provider-key", + "content-type": "application/json", + "x-safe": "visible", + }, + response: new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "set-cookie": "sid=response-token", + }, + }), + }); + await new Promise((resolve) => setImmediate(resolve)); + finalizeDebugProxyCapture(); + + const request = storeState.events.find((event) => event.kind === "request"); + expect(JSON.parse(String(request?.headersJson))).toMatchObject({ + Authorization: "[REDACTED]", + Cookie: "[REDACTED]", + "x-api-key": "[REDACTED]", + "content-type": "application/json", + "x-safe": "visible", + }); + const response = storeState.events.find((event) => event.kind === "response"); + expect(JSON.parse(String(response?.headersJson))).toMatchObject({ + "content-type": "application/json", + "set-cookie": "[REDACTED]", + }); + }); }); diff --git a/src/proxy-capture/runtime.ts b/src/proxy-capture/runtime.ts index 98c27828468..33b3ed8f8bc 100644 --- a/src/proxy-capture/runtime.ts +++ b/src/proxy-capture/runtime.ts @@ -15,6 +15,29 @@ import type { } from "./types.js"; const DEBUG_PROXY_FETCH_PATCH_KEY = Symbol.for("openclaw.debugProxy.fetchPatch"); +const REDACTED_CAPTURE_HEADER_VALUE = "[REDACTED]"; +const SENSITIVE_CAPTURE_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "auth-token", + "x-access-token", + "access-token", +]); +const SENSITIVE_CAPTURE_HEADER_NAME_FRAGMENTS = [ + "api-key", + "apikey", + "token", + "secret", + "password", + "credential", + "session", +]; type GlobalFetchPatchedState = { originalFetch: typeof globalThis.fetch; @@ -55,6 +78,32 @@ function resolveUrlString(input: RequestInfo | URL): string | null { return null; } +function isSensitiveCaptureHeaderName(name: string): boolean { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (SENSITIVE_CAPTURE_HEADER_NAMES.has(normalized)) { + return true; + } + return SENSITIVE_CAPTURE_HEADER_NAME_FRAGMENTS.some((fragment) => normalized.includes(fragment)); +} + +function redactedCaptureHeaders( + headers: Headers | Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const entries = + headers instanceof Headers ? Array.from(headers.entries()) : Object.entries(headers); + const redacted: Record = {}; + for (const [name, value] of entries) { + redacted[name] = isSensitiveCaptureHeaderName(name) ? REDACTED_CAPTURE_HEADER_VALUE : value; + } + return redacted; +} + function createHttpCaptureEventBase(params: { settings: DebugProxySettings; rawUrl: string; @@ -237,11 +286,7 @@ export function captureHttpExchange(params: { params.requestHeaders instanceof Headers ? (params.requestHeaders.get("content-type") ?? undefined) : params.requestHeaders?.["content-type"], - headersJson: safeJsonString( - params.requestHeaders instanceof Headers - ? Object.fromEntries(params.requestHeaders.entries()) - : params.requestHeaders, - ), + headersJson: safeJsonString(redactedCaptureHeaders(params.requestHeaders)), metaJson: safeJsonString(params.meta), ...requestPayload, }); @@ -268,7 +313,7 @@ export function captureHttpExchange(params: { : undefined, headersJson: params.response.headers && typeof params.response.headers.entries === "function" - ? safeJsonString(Object.fromEntries(params.response.headers.entries())) + ? safeJsonString(redactedCaptureHeaders(params.response.headers)) : undefined, metaJson: safeJsonString({ ...params.meta, bodyCapture: "unavailable" }), }); @@ -295,7 +340,7 @@ export function captureHttpExchange(params: { }), status: params.response.status, contentType: params.response.headers.get("content-type") ?? undefined, - headersJson: safeJsonString(Object.fromEntries(params.response.headers.entries())), + headersJson: safeJsonString(redactedCaptureHeaders(params.response.headers)), metaJson: safeJsonString(params.meta), ...responsePayload, });