security: redact every Responses input shape, near-miss endpoint paths and gate X-Tenant - #97
Conversation
…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.
| 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
left a comment
There was a problem hiding this comment.
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 anargumentsstring 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.
Three redaction gaps, each reproduced on main by a test in this PR.
1. Responses API input shapes forwarded unredacted
The
dialect === "responses"walk insrc/redact/apply.tsread only messagecontent,function_callarguments and a stringfunction_call_output. These reached the provider raw:function_call_output.outputas an array ofinput_textpartscustom_tool_call.inputandcustom_tool_call_output.output(string and part array)local_shell_call_output.outputcustomtool descriptionsprompt.variables(string values andinput_textvalues)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_fileparts,image_generation_callitems,image_url/file_id/file_url/file_data).argumentsis 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/responsesand/v1/./messageswere not exact matches, so the body was forwarded unread. fetch resolves dot segments, so on main the stub received/v1/responsesand/v1/messageswith raw PII.generationRoutenow 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_HEADERdefaulted totrue, so a caller could name a tenant withmode: offorallowHeaderOverrideand bypass "headers can only tighten". Now:TRUST_TENANT_HEADERdefaults tofalse; X-Tenant is ignored and the tenant comes from the API key.true,tenantSelectionViolationallows 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'ssrcthe 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.jshas three new Responses properties; the leak property fails on main.Verification
npm run typecheckclean. 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.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.