From 37ca2c69000ee8a233398fe482b3eff22a3e4c28 Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Tue, 18 Aug 2026 05:19:27 +0800 Subject: [PATCH] fix(parallel): redact x-api-key reflected in web search error bodies (#120205) * fix(parallel): redact x-api-key reflected in web search error bodies * fix(parallel): use canonical tool-payload redactor for reflected error bodies * fix(parallel): layer configured redactPatterns over header-shape redaction --- .../parallel-web-search-provider.runtime.ts | 14 +++- .../src/parallel-web-search-provider.test.ts | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts index 6cb4d6f4c47d..eefd4139063c 100644 --- a/extensions/parallel/src/parallel-web-search-provider.runtime.ts +++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; +import { redactToolPayloadText } from "openclaw/plugin-sdk/logging-core"; import { readProviderJsonResponse, readResponseTextLimited, @@ -16,6 +17,7 @@ import { withTrustedWebSearchEndpoint, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildParallelCacheKey, @@ -158,7 +160,17 @@ async function runParallelSearch(params: { const detail = await readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch( () => "", ); - throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`); + // Provider/proxy error pages can reflect request headers (including the + // x-api-key), and the empty-body statusText fallback is server-controlled + // too. Redact in two passes before the detail lands in user-facing error + // text: the tools-mode pass masks header-shaped reflections while the + // header name is intact (a configured pattern like api[_-]?key would + // otherwise rewrite the name first and hide the shape from the + // structured matcher), then the canonical tool-payload redactor applies + // the operator's logging.redactPatterns on top of the built-in defaults. + throw new Error( + `Parallel API error (${res.status}): ${redactToolPayloadText(redactSensitiveText(detail || res.statusText, { mode: "tools" }))}`, + ); } return await readProviderJsonResponse(res, "Parallel API", { maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES, diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts index c9d88c3cb296..c96f082389d2 100644 --- a/extensions/parallel/src/parallel-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; @@ -463,6 +466,86 @@ describe("parallel web search provider", () => { expect(tracked.wasCanceled()).toBe(true); expect(textSpy).not.toHaveBeenCalled(); }); + it("redacts reflected credentials from Parallel API error bodies", async () => { + // No dictionary words: the value must be masked even when only the + // header-shaped (x-api-key: ) redaction can catch it. + const apiKey = "par-live-4c9d2e7ab1f0c9d2e7ab1f0c9d2e7"; + endpointMockState.responses.push( + new Response(`edge failure for request with x-api-key: ${apiKey}`, { + status: 502, + statusText: "Bad Gateway", + headers: { "Content-Type": "text/html" }, + }), + ); + const error = await paidTool({ parallel: { apiKey } }) + .execute({ + objective: `parallel-error-redact-${Date.now()}`, + search_queries: ["openclaw"], + }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Parallel API error (502)"); + expect((error as Error).message).not.toContain(apiKey); + }); + it("redacts credentials reflected in the statusText fallback when the body is empty", async () => { + const apiKey = "par-live-4c9d2e7ab1f0c9d2e7ab1f0c9d2e7"; + endpointMockState.responses.push( + new Response("", { + status: 502, + statusText: `Bad Gateway reflected x-api-key: ${apiKey}`, + }), + ); + const error = await paidTool({ parallel: { apiKey } }) + .execute({ + objective: `parallel-error-redact-reason-${Date.now()}`, + search_queries: ["openclaw"], + }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Parallel API error (502)"); + expect((error as Error).message).not.toContain(apiKey); + }); + it("applies configured logging.redactPatterns to reflected Parallel error bodies", async () => { + // Organization-specific secret shape that no built-in pattern covers, plus a + // configured field-name pattern that would rewrite the x-api-key header name + // before the structured matcher can see it — the key value must stay masked. + const orgSecret = "acme-internal-bluefin-042"; + const apiKey = "par-live-4c9d2e7ab1f0c9d2e7ab1f0c9d2e7"; + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "parallel-redact-config-")); + const configPath = path.join(configDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ + logging: { redactPatterns: ["acme-internal-[a-z0-9-]+", "api[_-]?key"] }, + }), + ); + vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + try { + endpointMockState.responses.push( + new Response( + `edge failure for ${orgSecret} on request with x-api-key: ${apiKey}`, + { + status: 502, + statusText: "Bad Gateway", + headers: { "Content-Type": "text/html" }, + }, + ), + ); + const error = await paidTool({ parallel: { apiKey } }) + .execute({ + objective: `parallel-error-redact-config-${Date.now()}`, + search_queries: ["openclaw"], + }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Parallel API error (502)"); + expect((error as Error).message).not.toContain(orgSecret); + expect((error as Error).message).not.toContain(apiKey); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { force: true, recursive: true }); + } + }); it("bounds successful Parallel JSON bodies instead of buffering the whole response", async () => { const streamed = createStreamingResponse({ chunkCount: 200,