Skip to content

security: redact every Responses input shape, near-miss endpoint paths and gate X-Tenant - #97

Merged
askalf merged 1 commit into
mainfrom
fix/responses-redaction-coverage
Sep 26, 2026
Merged

askalf merged 1 commit into
mainfrom
fix/responses-redaction-coverage

Conversation

@askalf

@askalf askalf commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Three redaction gaps, each reproduced on main by a test in this PR.

1. Responses API input shapes forwarded unredacted

The dialect === "responses" walk in src/redact/apply.ts read only message content, function_call arguments and a string function_call_output. These reached the provider raw:

  • function_call_output.output as an array of input_text parts
  • custom_tool_call.input and custom_tool_call_output.output (string and part array)
  • local_shell_call_output.output
  • custom tool descriptions
  • prompt.variables (string values and input_text values)

Input items are now walked fail-closed (pushResponsesLeaves): every string leaf of every item is redacted, whatever the item type, including types added to the API later. Exempt, and documented next to the list: type, role, status, id, call_id, approval_request_id, name, model, encrypted_content, and media (input_image / input_file parts, image_generation_call items, image_url / file_id / file_url / file_data). arguments is parsed as JSON wherever it appears. Prompt variable names are the caller's keys, so the exemption never applies to them. An item nested past 16 levels throws, which the proxy turns into a fail-closed 422.

Restore path: reversible mode still round-trips (unit test plus a proxy test where the stub echoes tool output placeholders and the reply comes back with the real values).

2. Near-miss endpoint paths took the verbatim passthrough

/v1/chat/completions/, /v1/Chat/Completions, /v1/chat/completion%73, /v1/x/../responses, /v1/x/%2e%2e/responses and /v1/./messages were not exact matches, so the body was forwarded unread. fetch resolves dot segments, so on main the stub received /v1/responses and /v1/messages with raw PII. generationRoute now classifies the path after percent-decoding, lowercasing, collapsing slashes and backslashes, and resolving dot segments. The request is still forwarded to the path the client sent. /v1/messages/count_tokens/ still passes through.

3. X-Tenant let any caller pick a looser policy

TRUST_TENANT_HEADER defaulted to true, so a caller could name a tenant with mode: off or allowHeaderOverride and bypass "headers can only tighten". Now:

  • TRUST_TENANT_HEADER defaults to false; X-Tenant is ignored and the tenant comes from the API key.
  • When true, tenantSelectionViolation allows the selected tenant only if it is at least as strict as the caller's key-derived tenant on every knob: mode, activeSets (superset), failMode, redactSystem, consistentPseudonyms (false is stricter), allowHeaderOverride, and identical upstream bases. Otherwise 403, upstream not called.

Breaking for deployments that route callers with X-Tenant. Existing proxy tests that used X-Tenant to reach looser tenants now use key-derived tenants.

Fuzzing

fuzz/redact_leak.fuzz.ts (the Jazzer target from #89) never passed a dialect, so it only exercised chat and messages. Selector bit 2 now sends OpenAI inputs through the Responses walk with every shape above plus an unknown item type. It also checks that every field carrying the same text is de-identified exactly like the message field holding it, which the claimed-value budget could not catch. Two Responses seeds were added. On main's src the target throws on both new seeds and on existing seeds routed to Responses; 45s per target under Jazzer is clean with the fix. _test_fuzz.js has three new Responses properties; the leak property fails on main.

Verification

  • npm run typecheck clean. There is no build step (tsx); the fuzz bundles build through esbuild.
  • npm test: unit 104/104, fuzz 18/18, proxy 115/115, stream 31/31.
  • Same tests against main's src: unit 13 failures, proxy 29 failures, fast-check 1 failure.

CHANGELOG, README, docs/reference.md, .env.example, docker-compose.yml and the repo development notes are updated.

…s and gate X-Tenant

Responses API: function_call_output output in its part-array form,
custom_tool_call input, custom_tool_call_output output,
local_shell_call_output output, custom tool descriptions and
prompt.variables reached the provider unredacted. Input items are now
walked fail-closed: every string leaf of every item is redacted, including
item types cordon does not know, except the structural keys the provider
needs byte-exact and media parts. An item nested past the depth cap is
refused instead of forwarded partly unread.

Paths: a near-miss spelling of a generation endpoint (trailing slash, dot
segments, case, percent-escapes) took the verbatim passthrough with its
body unread, and fetch resolves dot segments, so /v1/x/../responses
reached /v1/responses raw. generationRoute classifies the path the way the
upstream can read it.

X-Tenant: TRUST_TENANT_HEADER now defaults to false. When set to true, the
header may only select a tenant whose policy is at least as strict as the
caller's key-derived one on every knob, with identical upstream bases;
anything looser is refused with 403 before the upstream is called.
Breaking for deployments that route callers with X-Tenant.

The redact_leak Jazzer target and the fast-check battery now exercise
dialect "responses", including a walk-coverage check that fails on the
previous walk.
@github-actions github-actions Bot added documentation Improvements or additions to documentation redaction Redaction, pseudonyms, reidentification proxy The fail-closed request path tests Test suite and CI fuzz Fuzzing and ClusterFuzzLite size/L 200-799 hand-written lines labels Sep 26, 2026
Comment thread _test_proxy.mjs
for (const path of ["/v1/chat/completions/", "/v1/Chat/Completions", "/v1/chat/completion%73"]) {
await reset();
res = await post(path, oBody(PII));
text = (await res.json().catch(() => ({})))?.choices?.[0]?.message?.content || "";

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve. Replaces the per-type Responses input walk with a fail-closed leaf walk (structural keys and media parts exempt, depth cap throws into the 422 path), classifies generation endpoints after decoding, lowercasing and dot-segment resolution while still forwarding the caller's path, and gates a trusted X-Tenant so it can only select a policy at least as strict as the key-derived one on every knob. I traced the walk over message, tool-call, tool-output, unknown-item and prompt-variable shapes, the path normaliser over the listed near-misses and the count_tokens sub-path, and the tenant comparison against headerOverrideViolation ordering in index.ts; the unit, proxy and property tests cover each new path and check the upstream never received the raw values.

Minor:

  • src/redact/apply.ts:107: The walk throws past MAX_LEAF_DEPTH so no leaf is forwarded unread, but an arguments string is handed to pushJsonString, whose pushStringLeaves silently returns at depth > 16 instead of throwing. A function_call whose arguments JSON nests a value 17 levels deep is forwarded with that value raw, which is the outcome the surrounding walk was changed to refuse. The same silent stop already applies to chat tool_calls, so this is a consistency gap rather than a regression.

@askalf
askalf merged commit 1390f62 into main Sep 26, 2026
12 checks passed
@askalf
askalf deleted the fix/responses-redaction-coverage branch September 26, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation fuzz Fuzzing and ClusterFuzzLite proxy The fail-closed request path redaction Redaction, pseudonyms, reidentification size/L 200-799 hand-written lines tests Test suite and CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants