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
This commit is contained in:
zengLingbiao
2026-08-18 05:19:27 +08:00
committed by GitHub
parent 2f653a73c2
commit 37ca2c6900
2 changed files with 96 additions and 1 deletions
@@ -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<ParallelSearchResponse>(res, "Parallel API", {
maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
@@ -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: <value>) redaction can catch it.
const apiKey = "par-live-4c9d2e7ab1f0c9d2e7ab1f0c9d2e7";
endpointMockState.responses.push(
new Response(`<html><body>edge failure for request with x-api-key: ${apiKey}</body></html>`, {
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(
`<html><body>edge failure for ${orgSecret} on request with x-api-key: ${apiKey}</body></html>`,
{
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,