From cd04c6f88d77231e5dab7f2aa748050d96964988 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:45:03 +0000 Subject: [PATCH] fix(secrets): Do not treat x-session-affinity as a credential AI Gateway clients send a conversation id in x-session-affinity. The proxy learned it as a credential and replaced it with <> in the model responses that the client got. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- README.md | 5 +++-- src/secrets.ts | 9 +++++++++ tests/service.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 367da4f..0dca2b2 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,9 @@ A run takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`, goes to a model in a prompt. The service keeps them only in memory, for the run. A run can have 32 of them. It also learns the value of each request header whose name can mean a credential, such as one with `auth`, `token`, `key`, `secret`, `cookie`, or `session`. - The match is broad on purpose. Recordings show `<>` in place of - each known value. + The match is broad on purpose. It does not learn `x-session-affinity`, + because that header has a conversation id and not a credential. + Recordings show `<>` in place of each known value. Requests that match no rule go live without a change, and their responses stream. Each response has an `x-roach` header: `replayed`, `live`, diff --git a/src/secrets.ts b/src/secrets.ts index ad7ac79..d6c7a9b 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -13,6 +13,14 @@ import type { IncomingHttpHeaders } from "node:http"; */ const CREDENTIAL_HEADER = /auth|token|secret|key|cookie|session|password|credential|signature/i; +/** + * Headers that match `CREDENTIAL_HEADER` but carry no credential. A response + * can show their values, and the client must get them back without a change. + * AI Gateway clients put a conversation id in `x-session-affinity`, and a + * model can repeat that id in its response. Node gives header names in + * lower case. + */ +const NOT_CREDENTIAL_HEADERS = new Set(["x-session-affinity"]); /** Shorter parts, such as `Bearer`, are not credentials. */ const MIN_LENGTH = 12; const REDACTED = "<>"; @@ -41,6 +49,7 @@ export function createSecrets(initial: string[] = []) { learn(headers: IncomingHttpHeaders): void { for (const [name, value] of Object.entries(headers)) { if (!CREDENTIAL_HEADER.test(name) || value === undefined) continue; + if (NOT_CREDENTIAL_HEADERS.has(name)) continue; for (const item of Array.isArray(value) ? value : [value]) add(item); } }, diff --git a/tests/service.test.ts b/tests/service.test.ts index bce2c27..581e6aa 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -91,6 +91,7 @@ beforeEach(async () => { n: liveRequests, key: incoming.headers["x-api-key"] ?? null, echo: incoming.headers["x-echo"] ?? null, + affinity: incoming.headers["x-session-affinity"] ?? null, serviceSecret: "service-secret-value-123", }), ); @@ -386,6 +387,7 @@ describe("roach service", () => { n: 1, key: "<>", echo: "<>", + affinity: null, serviceSecret: "<>", }); const [file] = await recordings("acme/alpha"); @@ -397,4 +399,30 @@ describe("roach service", () => { expect(text).not.toContain("service-secret-value-123"); expect(text).not.toContain("run-secret-value-789"); }); + + it("does not redact the session affinity header", async () => { + // AI Gateway clients send a conversation id in `x-session-affinity`. + // It is not a credential, and the client must get it back as it is. + const run = await startRun("write-token", "auto"); + const response = await send( + run.url, + { prompt: "hi" }, + { + "x-api-key": "sk-live-request-key-456", + "x-session-affinity": "conversation-id-0123456789", + }, + ); + + expect(JSON.parse(response.body)).toMatchObject({ + key: "<>", + affinity: "conversation-id-0123456789", + }); + const [file] = await recordings("acme/alpha"); + const text = await readFile( + path.join(directory, "acme/alpha", "model", file!), + "utf8", + ); + expect(text).toContain("conversation-id-0123456789"); + expect(text).not.toContain("sk-live-request-key-456"); + }); });