fix(diagnostics): harden capture redaction and discord metadata fetch (#71303)

This commit is contained in:
Vincent Koc
2026-04-24 17:51:12 -07:00
committed by GitHub
parent 25a02825a5
commit 718dffd2f2
6 changed files with 111 additions and 63 deletions

View File

@@ -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<typeof emitDiagnosticEvent>[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);
});

View File

@@ -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 {

View File

@@ -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<string>(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<string, string> | 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

View File

@@ -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();
});

View File

@@ -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<string, unknown>[] = [];
@@ -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]",
});
});
});

View File

@@ -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<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const entries =
headers instanceof Headers ? Array.from(headers.entries()) : Object.entries(headers);
const redacted: Record<string, string> = {};
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,
});