summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 17:23:13 +0900
committerAdam Malczewski <[email protected]>2026-06-05 17:23:13 +0900
commit5ae88d4e98b3b585526ab0534be8e4c12f1ac071 (patch)
tree86d6b83ac02f978979dc76fefa2308234e17d06a /packages
parent64a66d5e41c5ab54a1d0a7712866be722b742d4b (diff)
downloaddispatch-5ae88d4e98b3b585526ab0534be8e4c12f1ac071.tar.gz
dispatch-5ae88d4e98b3b585526ab0534be8e4c12f1ac071.zip
fix(observability): record-mode redaction leaked capitalized Authorization header — case-insensitive mask (+3 regression tests)
A live capture exposed that provider record mode saved the request Authorization header with the API key in CLEARTEXT: onExchange redaction matched lowercase 'authorization', but streamChat sends 'Authorization' (capital A) and recordFetch captures headers verbatim, so the real header slipped through. The provider.request SPAN redaction was unaffected (it masks from config.apiKey directly — journal + trace-DB showed zero leaks); the leak was record-mode-only and caught PRE-COMMIT (fixture was /tmp-only, scrubbed). Fix: redact auth case-insensitively across all captured header keys (strip Bearer, maskSecret the token, re-prepend, preserve key casing). New tests: reproduce the exact capital-Authorization leak (would have caught it), a lowercase case, and a guard that no authorization header of ANY casing survives carrying a raw sk- token. 334 tests (331 -> +3), typecheck + biome 0/0. This is the live-capture step (D5) earning its keep — real data exposed what the synthetic redaction test assumed away.
Diffstat (limited to 'packages')
-rw-r--r--packages/provider-openai-compat/src/stream.test.ts144
-rw-r--r--packages/provider-openai-compat/src/stream.ts11
2 files changed, 146 insertions, 9 deletions
diff --git a/packages/provider-openai-compat/src/stream.test.ts b/packages/provider-openai-compat/src/stream.test.ts
index ad0f37e..25fd84e 100644
--- a/packages/provider-openai-compat/src/stream.test.ts
+++ b/packages/provider-openai-compat/src/stream.test.ts
@@ -663,9 +663,14 @@ describe("streamChat — record-mode redaction (trace-replay)", () => {
headers: { "content-type": "text/event-stream" },
}),
(fx) => {
- const redactedHeaders = { ...fx.request.headers };
- if (redactedHeaders.authorization) {
- redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`;
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
}
capturedFixture = {
request: { ...fx.request, headers: redactedHeaders },
@@ -703,14 +708,141 @@ describe("streamChat — record-mode redaction (trace-replay)", () => {
expect(serialized).toContain("hi");
});
+ it("redacts capitalized Authorization header (the real leak casing)", async () => {
+ const apiKey = "sk-LIVEKEY1234567890abcdef";
+ const responseBody = "data: [DONE]\n";
+
+ let capturedFixture: HttpExchangeFixture | undefined;
+ const wrappedFetch = recordFetch(
+ async () =>
+ new Response(responseBody, {
+ status: 200,
+ headers: { "content-type": "text/event-stream" },
+ }),
+ (fx) => {
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
+ }
+ capturedFixture = {
+ request: { ...fx.request, headers: redactedHeaders },
+ response: fx.response,
+ };
+ },
+ );
+
+ await wrappedFetch("https://api.example.com/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: '{"model":"test","messages":[],"stream":true}',
+ });
+
+ assertDefined(capturedFixture);
+ expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def");
+ expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc");
+
+ const serialized = serializeFixture(capturedFixture);
+ expect(serialized).not.toContain("LIVEKEY1234567890abc");
+ expect(serialized).toContain("Bearer sk-…redacted…def");
+ });
+
+ it("redacts lowercase authorization header", async () => {
+ const apiKey = "sk-abcdefghijkmnop";
+
+ let capturedFixture: HttpExchangeFixture | undefined;
+ const wrappedFetch = recordFetch(
+ async () => new Response("data: [DONE]\n", { status: 200 }),
+ (fx) => {
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
+ }
+ capturedFixture = {
+ request: { ...fx.request, headers: redactedHeaders },
+ response: fx.response,
+ };
+ },
+ );
+
+ await wrappedFetch("https://api.example.com/v1/chat/completions", {
+ method: "POST",
+ headers: { authorization: `Bearer ${apiKey}` },
+ body: null,
+ });
+
+ assertDefined(capturedFixture);
+ expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop");
+ expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm");
+ });
+
+ it("guard: no header named authorization (any case) survives with a raw sk- token", async () => {
+ const apiKey = "sk-REALKEY_1234567890abcdef";
+
+ let capturedFixture: HttpExchangeFixture | undefined;
+ const wrappedFetch = recordFetch(
+ async () => new Response("data: [DONE]\n", { status: 200 }),
+ (fx) => {
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
+ }
+ capturedFixture = {
+ request: { ...fx.request, headers: redactedHeaders },
+ response: fx.response,
+ };
+ },
+ );
+
+ await wrappedFetch("https://api.example.com/v1/chat/completions", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: null,
+ });
+
+ assertDefined(capturedFixture);
+ for (const [key, value] of Object.entries(capturedFixture.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ expect(value).not.toContain(apiKey);
+ expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/);
+ }
+ }
+
+ const serialized = serializeFixture(capturedFixture);
+ expect(serialized).not.toContain(apiKey);
+ expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/);
+ });
+
it("redacts a short API key (≤7 chars → full mask)", async () => {
let capturedFixture: HttpExchangeFixture | undefined;
const wrappedFetch = recordFetch(
async () => new Response("data: [DONE]\n", { status: 200 }),
(fx) => {
- const redactedHeaders = { ...fx.request.headers };
- if (redactedHeaders.authorization) {
- redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`;
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
}
capturedFixture = {
request: { ...fx.request, headers: redactedHeaders },
diff --git a/packages/provider-openai-compat/src/stream.ts b/packages/provider-openai-compat/src/stream.ts
index 7d1f2c8..7cefb25 100644
--- a/packages/provider-openai-compat/src/stream.ts
+++ b/packages/provider-openai-compat/src/stream.ts
@@ -106,9 +106,14 @@ export async function* streamChat(
const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay");
effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => {
try {
- const redactedHeaders = { ...fx.request.headers };
- if (redactedHeaders.authorization) {
- redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`;
+ const redactedHeaders: Record<string, string> = {};
+ for (const [key, value] of Object.entries(fx.request.headers)) {
+ if (key.toLowerCase() === "authorization") {
+ const token = value.replace(/^Bearer\s+/i, "");
+ redactedHeaders[key] = `Bearer ${maskSecret(token)}`;
+ } else {
+ redactedHeaders[key] = value;
+ }
}
const redacted: HttpExchangeFixture = {
request: { ...fx.request, headers: redactedHeaders },