mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(diagnostics): harden capture redaction and discord metadata fetch (#71303)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user