summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--packages/provider-openai-compat/src/stream.test.ts144
-rw-r--r--packages/provider-openai-compat/src/stream.ts11
-rw-r--r--tasks.md14
3 files changed, 156 insertions, 13 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 },
diff --git a/tasks.md b/tasks.md
index e645eca..55ea9ff 100644
--- a/tasks.md
+++ b/tasks.md
@@ -255,10 +255,16 @@ independent of the SQLite trace-store; the lib is redaction-free (caller self-re
44→48 tests → **331 vitest**, typecheck + biome 0/0. CR: none.
prompts/provider-trace-replay.md, reports/provider-trace-replay.md.
- [x] **Build wiring** (orchestrator): root tsconfig ref ✓; provider dep `@dispatch/trace-replay` ✓; `bun install` ✓.
-- [ ] **Live capture** (orchestrator): boot with `DISPATCH_RECORD_FIXTURE` + real key →
- capture a real flash exchange → secret-free check → re-summon provider to swap the
- synthetic text-turn fixture for the real one + update expected text (validates our SSE
- assumptions against reality — D5).
+- [~] **Live capture** (orchestrator): FOUND A BUG (the whole point of D5). The real
+ capture leaked the live API key — record-mode self-redaction MISSED the `Authorization`
+ header (capital A + `Bearer ` prefix; redaction matched lowercase `authorization`).
+ Committed span/journal path is SAFE (journal + trace-DB = ZERO_LEAKS); only the
+ record-mode fixture path leaked, caught PRE-COMMIT (fixture was /tmp-only, scrubbed).
+- [ ] **Record-mode redaction fix** (provider owner): case-insensitive auth-header mask +
+ a test using the REAL `Authorization: Bearer …` representation (must fail before, pass
+ after). prompts/provider-trace-replay-fix.md.
+- [ ] **Re-capture + swap** (orchestrator): after the fix, re-run live capture → secret
+ check → re-summon to commit the clean real fixture + update assertions.
Summons: prompts/phase-a-{kernel-logging,journal-sink}.md;
reports/phase-a-{kernel-logging,journal-sink}.md.