mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user