From 6fa59c035c470ac34d83a674c9782d15ac24d730 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:31:32 -0400 Subject: [PATCH] security: redact every Responses input shape, near-miss endpoint paths 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. --- .env.example | 7 +- CHANGELOG.md | 5 +- CLAUDE.md | 4 +- README.md | 3 +- _run_tests.mjs | 6 +- _stub-upstream.mjs | 4 +- _test_fuzz.js | 60 ++++++++++ _test_proxy.mjs | 126 ++++++++++++++++++-- _test_unit.mjs | 84 +++++++++++++ docker-compose.yml | 2 +- docs/reference.md | 2 +- fuzz/redact_leak.fuzz.ts | 92 ++++++++++++-- fuzz/seeds/redact_leak/responses_reversible | Bin 0 -> 126 bytes fuzz/seeds/redact_leak/responses_strip | Bin 0 -> 126 bytes src/config.ts | 8 +- src/index.ts | 23 +++- src/providers.ts | 108 ++++++++++++++--- src/redact/apply.ts | 93 ++++++++++++--- 18 files changed, 560 insertions(+), 67 deletions(-) create mode 100644 fuzz/seeds/redact_leak/responses_reversible create mode 100644 fuzz/seeds/redact_leak/responses_strip diff --git a/.env.example b/.env.example index f3d0f80..40ce9b2 100644 --- a/.env.example +++ b/.env.example @@ -35,9 +35,10 @@ AUDIT_LOG=./audit.jsonl ADMIN_TOKEN= # required header x-admin-token for /admin/*; empty = admin API disabled (403) # ALLOW_OPEN_ADMIN=1 # dev only: with ADMIN_TOKEN empty, serve /admin/* with NO auth TENANT_FROM_AUTH=true # when no X-Tenant, derive the tenant from the API key -# Honour the caller's X-Tenant header. false = ignore it; the tenant always comes from the -# API key, so a caller can't select another tenant's (possibly looser) policy. -TRUST_TENANT_HEADER=true +# Honour the caller's X-Tenant header. false (the default) = ignore it; the tenant always +# comes from the API key. true = honour it, but only for a tenant whose policy is at least as +# strict as the caller's own (403 otherwise); it still picks the audit/metrics tenant label. +TRUST_TENANT_HEADER=false # Per-request X-Redact-Mode / X-Redact-Sets may only TIGHTEN policy by default (off, or a # narrower set list, is refused with 403). true = callers may loosen it (per tenant: # "allowHeaderOverride": true via /admin/tenant). diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f69c6..32bb90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ image and creates the GitHub release from this file. ### Security +- The Responses API request walk reads every input item. `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` (string and `input_text` values) used to reach 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 structural fields the provider needs byte-exact (`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`). An item nested more than 16 levels deep is refused (422) rather than forwarded partly unread. +- A near-miss spelling of a generation endpoint (`/v1/chat/completions/`, `/v1/x/../responses`, `/v1/./messages`, `/v1/Chat/Completions`, `/v1/chat/completion%73`) took the verbatim passthrough and forwarded the raw body; the upstream fetch resolves dot segments, so some of these reached the real endpoint with PII intact. The path is now classified the way the upstream can read it and such a request is redacted. It is still forwarded to the path the client sent. +- `X-Tenant` is ignored unless `TRUST_TENANT_HEADER=true`. Before, any caller could name any tenant and get its policy, including one with `mode: off` or `allowHeaderOverride`, which made "headers can only tighten" untrue by default. When the header is trusted, it may only select a tenant whose policy is at least as strict as the caller's own (mode, sets, fail mode, `redactSystem`, `consistentPseudonyms`, `allowHeaderOverride`, and identical upstream bases); anything looser is refused with 403 before the upstream is called. **Breaking** for deployments that route callers to tenants with `X-Tenant`: set `TRUST_TENANT_HEADER=true`, and move tenants with a looser or residency policy to key-derived tenants. - Per-request headers can no longer weaken redaction. `X-Redact-Mode` may only be as strong as or stronger than the tenant/global mode (`off` < `reversible` < `strip`), and `X-Redact-Sets` must include every policy set; `X-Redact-Mode: off` or a narrower set list is refused with 403 and the upstream is never called. Until now any caller could send `X-Redact-Mode: off` and forward raw PII. Opt back in per tenant (`"allowHeaderOverride": true`) or globally (`ALLOW_HEADER_OVERRIDE=true`). **Breaking** for clients that relied on loosening headers. - The admin API is disabled (403) when `ADMIN_TOKEN` is unset, instead of open to anyone who can reach the port. `ALLOW_OPEN_ADMIN=1` restores the open dev behaviour. The token is compared in constant time. **Breaking** for deployments that ran `/admin/*` without a token. - Upstream failures return a fixed message and a `requestId` (also `X-Request-Id`) instead of the raw exception, which could name internal hosts such as a residency upstream; the detail is logged server-side. @@ -22,7 +25,7 @@ image and creates the GitHub release from this file. ### Added - `UPSTREAM_TIMEOUT_MS` (default 600000): a provider that sends no response headers in time gets a 504 instead of holding the connection forever. Once headers arrive the body, including a long stream, is not timed. -- `TRUST_TENANT_HEADER` (default `true`): set `false` to ignore `X-Tenant` and always derive the tenant from the API key, so callers can't select another tenant's policy. +- `TRUST_TENANT_HEADER` (default `false`): set `true` to honour `X-Tenant`, limited to tenants at least as strict as the caller's own (see Security). ## [0.3.0] - 2026-09-22 diff --git a/CLAUDE.md b/CLAUDE.md index fc6d460..cd0a0da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,9 @@ Append-only JSONL where `hash = sha256(prevHash + canonicalJSON(record))`. Appen The vault is per-request, policy is in-memory (`policy.ts`), the audit log is a local file — so cordon runs as a **single service with no Redis/DB** (unlike deja). Don't add a cache/datastore dependency without a real reason. Policy has **optional** file-backed durability (`POLICY_STORE`): `policy.load()` runs before `app.listen` and `policy.save()` fires on every `setPolicy` (serialized through a tail promise, mirroring the audit append; fail-safe on absent/malformed files). Unset = pure in-memory, the default path untouched — still no datastore. ## Conventions & gotchas -- Only `/v1/chat/completions` and `/v1/messages` are redacted; every other `/v1/*` path (count_tokens, embeddings, models) forwards **verbatim** via `passthroughUnknown` and must never be normalized. +- Only `/v1/chat/completions`, `/v1/responses` and `/v1/messages` are redacted; every other `/v1/*` path (count_tokens, embeddings, models) forwards **verbatim** via `passthroughUnknown` and its body must never be normalized. Which paths count as a generation endpoint is decided by `providers.generationRoute`, which reads the path the way the upstream can (trailing slash, dot segments, case, percent-escapes), so a near-miss spelling is redacted rather than passed through. +- The Responses request walk (`pushResponsesLeaves` in `redact/apply.ts`) is fail-closed over item types: every string leaf of every input item is redacted except the structural keys in `RESPONSES_STRUCTURAL_KEYS` and media parts. Add a key there only if the upstream needs it byte-exact and the model does not read it as content. +- `X-Tenant` is ignored unless `TRUST_TENANT_HEADER=true`; when honoured, `providers.tenantSelectionViolation` refuses (403) a tenant whose policy is looser than the caller's key-derived one. - The brand string lives in `config.brand` — don't hardcode "cordon" in user-facing strings. - `CORDON_TEST_HOOKS=1` enables the `X-Cordon-Fail: 1` header that forces a detection failure (to exercise fail-closed). It is **off by default** — never rely on it in production paths. - Provider auth headers (`authorization`/`x-api-key`/`anthropic-version`) are forwarded verbatim; cordon never terminates provider auth. diff --git a/README.md b/README.md index 7812611..240b27b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ All four sets are on by default. Narrow them per tenant; a request can add sets - **`strip`**: irreversible placeholders (`[EMAIL]`); nothing is restored. For when the answer never needs the real value. - **`off`**: passthrough, still audited as a bypass. - **Policy is a floor.** A caller's `X-Redact-Mode` / `X-Redact-Sets` can only make redaction stricter than the tenant or global policy; `X-Redact-Mode: off` or a narrower set list is refused with 403 and never forwarded. Allow loosening per tenant (`allowHeaderOverride`) or globally (`ALLOW_HEADER_OVERRIDE=true`). +- **The tenant comes from the API key.** `X-Tenant` is ignored unless you set `TRUST_TENANT_HEADER=true`, and even then it can only select a tenant whose policy is at least as strict as the one the caller's key already gets (403 otherwise). It does choose the tenant name recorded in the audit log and metrics. - **Admin API is off until you set `ADMIN_TOKEN`.** Without it `/admin/*` returns 403 rather than running open. - **Tamper-evident audit.** Every request appends a hash-chained record of counts and types, never values; `npm run audit` verifies the chain. - **Per-tenant policy**: consistent pseudonyms, data residency (regional upstreams), durable policy store. @@ -61,7 +62,7 @@ X-Redact-Mode: strip → "text":"email [EMAIL] re card [CREDIT_CARD]" ## What it does not do - **Names, free-text addresses, medical conditions.** There is no NER. A person's name in prose passes through. The detector is an interface (`src/detect`), so a Presidio-style sidecar can be added; it is not included. -- **Embeddings, `count_tokens`, images.** Only the three generation endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) are redacted; other `/v1/*` paths, including `/v1/responses/{id}`, pass through verbatim. Image and file parts are left untouched. +- **Embeddings, `count_tokens`, images.** Only the three generation endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) are redacted; other `/v1/*` paths, including `/v1/responses/{id}`, pass through verbatim. A spelling of a generation endpoint the provider can still resolve (a trailing slash, `.`/`..` segments, a different case, percent-escapes) is redacted like the endpoint itself. Image and file parts are left untouched. - **Token counts.** Streaming usage figures are the provider's, computed on the de-identified text. If you need one of those, say so in an issue. The scope above is deliberate, not accidental. diff --git a/_run_tests.mjs b/_run_tests.mjs index a09dd15..3ed384e 100644 --- a/_run_tests.mjs +++ b/_run_tests.mjs @@ -11,10 +11,12 @@ const base = { OPENAI_BASE: STUB, ANTHROPIC_BASE: STUB }; // port → instance env const instances = { - // Primary instance: has a strong TENANT_SECRET, so consistent-pseudonym requests work. - 8810: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test.jsonl", CORDON_TEST_HOOKS: "1", TENANT_SECRET: "test-secret-0123456789abcdef" }, + // Primary instance: has a strong TENANT_SECRET, so consistent-pseudonym requests work, and + // opts in to X-Tenant (TRUST_TENANT_HEADER) so the tenant-selection rule is exercised. + 8810: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test.jsonl", CORDON_TEST_HOOKS: "1", TENANT_SECRET: "test-secret-0123456789abcdef", TRUST_TENANT_HEADER: "true" }, // Secret-less instance: consistent pseudonyms enabled per-tenant here must FAIL CLOSED // (no ALLOW_WEAK_PSEUDONYM_SECRET) — exercises the per-request pseudonym-secret guard. + // TRUST_TENANT_HEADER is left at its default, so X-Tenant is ignored here. 8811: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test_nosecret.jsonl" }, // No ADMIN_TOKEN; an Anthropic upstream accepts connections but never answers. // The proxy suite opens it on :8901 with a short timeout for the 504 path. diff --git a/_stub-upstream.mjs b/_stub-upstream.mjs index 985410d..cee6330 100644 --- a/_stub-upstream.mjs +++ b/_stub-upstream.mjs @@ -30,7 +30,9 @@ function collectText(body, provider) { if (typeof body.input === "string") parts.push(body.input); else for (const item of body.input || []) { if (item?.role === "user") pushContent(item.content); - if (item?.type === "function_call_output" && typeof item.output === "string") parts.push(item.output); + // Tool outputs of every kind (a string, or input_text parts) and a custom tool's input. + if (/_call_output$/.test(item?.type ?? "")) pushContent(item.output); + if (item?.type === "custom_tool_call" && typeof item.input === "string") parts.push(item.input); } return parts.join(" "); } diff --git a/_test_fuzz.js b/_test_fuzz.js index 7b866bb..a197bd3 100644 --- a/_test_fuzz.js +++ b/_test_fuzz.js @@ -341,5 +341,65 @@ prop( ), ); +// ---------------- Responses API walk ---------------- + +// Every Responses input shape a client can send text in, including an item type the walk +// has no case for (a random name, so no allow-list can pass it by accident). +const itemTypeName = fc.stringMatching(/^[a-z]{3,12}_(item|call|call_output)$/); +const responsesBody = (t, unknownType) => ({ + model: "m", + instructions: t, + input: [ + { role: "user", content: t }, + { role: "user", content: [{ type: "input_text", text: t }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: t, annotations: [] }] }, + { type: "function_call", call_id: "c1", name: "f", arguments: JSON.stringify({ q: t }) }, + { type: "function_call_output", call_id: "c1", output: t }, + { type: "function_call_output", call_id: "c1", output: [{ type: "input_text", text: t }] }, + { type: "custom_tool_call", call_id: "c2", name: "g", input: t }, + { type: "custom_tool_call_output", call_id: "c2", output: [{ type: "input_text", text: t }] }, + { type: "local_shell_call_output", call_id: "c3", output: t }, + { type: unknownType, id: "x1", body: { text: t, lines: [t] } }, + ], + tools: [{ type: "function", name: "f", description: t, parameters: { example: t } }, { type: "custom", name: "g", description: t }], + prompt: { id: "p1", variables: { name: t, v: { type: "input_text", text: t } } }, +}); + +prop( + "Responses: seeded PII never survives in any input shape, known or unknown item type", + fc.property(seededText, itemTypeName, fc.constantFrom("reversible", "strip"), ({ text, seeds }, unknownType, mode) => { + const body = responsesBody(text, unknownType); + const before = JSON.stringify(body); + const { deidBody } = applyRedaction(body, "openai", new Vault(mode), ALL, detector, true, "responses"); + const out = JSON.stringify(deidBody); + return !seeds.some((s) => out.includes(s.value)) && JSON.stringify(body) === before; + }), +); + +prop( + "Responses: redact→re-identify round-trips to identity on arbitrary text", + fc.property(cleanText, itemTypeName, (text, unknownType) => { + const v = new Vault("reversible"); + const { deidBody } = applyRedaction(responsesBody(text, unknownType), "openai", v, ALL, detector, true, "responses"); + // Each field was de-identified on its own; every one must restore to the original. + const deids = [deidBody.input[0].content, deidBody.input[6].input, deidBody.input[9].body.text, deidBody.prompt.variables.name]; + return deids.every((d) => { + const resp = { output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: d }] }] }; + return reidentifyBody(resp, "openai", v, "responses").output[0].content[0].text === text; + }); + }), +); + +prop( + "Responses: applyRedaction is total on arbitrary input items and never mutates its input", + fc.property(fc.array(fc.jsonValue({ maxDepth: 8 }), { maxLength: 5 }), fc.jsonValue({ maxDepth: 4 }), (input, variables) => { + const body = { model: "m", input, prompt: { id: "p", variables } }; + const before = JSON.stringify(body); + const { deidBody } = applyRedaction(body, "openai", new Vault("reversible"), ALL, detector, true, "responses"); + JSON.stringify(deidBody); + return JSON.stringify(body) === before; + }), +); + console.log(`\nfuzz: ${pass} passed, ${fail} failed (${RUNS} runs/property)`); process.exit(fail ? 1 : 0); diff --git a/_test_proxy.mjs b/_test_proxy.mjs index 3bc35d7..0d0d9b7 100644 --- a/_test_proxy.mjs +++ b/_test_proxy.mjs @@ -1,7 +1,9 @@ // Integration: cordon (:8810) in front of the echo stub (:8900). Proves the model // never sees raw PII, reversible restores it, strip/off behave, fail-closed blocks, // and the audit log verifies and holds no values. Run with: node _test_proxy.mjs +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; +import http from "node:http"; import { createServer } from "node:net"; const BASE = "http://localhost:8810"; @@ -32,11 +34,29 @@ const setTenantOn = (base) => (patch) => fetch(base + "/admin/tenant", { method: "POST", headers: { "content-type": "application/json", "x-admin-token": ADMIN }, body: JSON.stringify(patch) }); const setTenant = setTenantOn(BASE); const setTenant2 = setTenantOn(BASE2); +// The tenant cordon derives from an API key (TENANT_FROM_AUTH). A policy that is looser than +// the default can only be reached this way: X-Tenant may not select one. +const keyTenant = (key) => "auth:" + createHash("sha256").update(key).digest("hex").slice(0, 16); +const asKey = (key) => ({ "x-api-key": key }); +// POST with the path sent byte-for-byte: fetch would resolve dot segments before sending. +const rawPost = (path, body) => + new Promise((resolve, reject) => { + const req = http.request( + { host: "localhost", port: 8810, path, method: "POST", headers: { "content-type": "application/json", "x-api-key": "test-key" } }, + (res) => { + let b = ""; + res.on("data", (c) => (b += c)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, json: () => JSON.parse(b) })); + }, + ); + req.on("error", reject); + req.end(JSON.stringify(body)); + }); (async () => { // A tenant whose callers may loosen policy per request (X-Redact-Mode: off, narrower // X-Redact-Sets). Everyone else can only tighten it. - await setTenant({ tenant: "loose", allowHeaderOverride: true }); + await setTenant({ tenant: keyTenant("loose-key"), allowHeaderOverride: true }); // ---- reversible (Anthropic) ---- await reset(); @@ -95,7 +115,7 @@ const setTenant2 = setTenantOn(BASE2); // ---- off (passthrough), for a tenant allowed to loosen ---- await reset(); - res = await post("/v1/messages", aBody(PII), { "x-tenant": "loose", "x-redact-mode": "off" }); + res = await post("/v1/messages", aBody(PII), { ...asKey("loose-key"), "x-redact-mode": "off" }); text = (await res.json())?.content?.[0]?.text || ""; ok("off: reply echoes raw (nothing redacted)", text.includes("john@acme.com")); ok("off: X-Redacted is 0", res.headers.get("x-redacted") === "0"); @@ -140,6 +160,26 @@ const setTenant2 = setTenantOn(BASE2); } ok("responses/items: X-Redacted counts every field", Number(res.headers.get("x-redacted")) >= 5, res.headers.get("x-redacted")); + // ---- reversible (Responses): tool outputs of every kind, custom tools, prompt variables ---- + await reset(); + res = await post("/v1/responses", rBody( + [ + { role: "user", content: [{ type: "input_text", text: "summarise the tool results" }] }, + { type: "function_call_output", call_id: "call_1", output: [{ type: "input_text", text: "owner john@acme.com" }] }, + { type: "custom_tool_call", call_id: "call_2", name: "send_mail", input: "to: jane@corp.io" }, + { type: "custom_tool_call_output", call_id: "call_2", output: "queued for jane@corp.io" }, + { type: "local_shell_call_output", id: "lsh_1", call_id: "call_3", output: "users.csv: ops@acme.com" }, + { type: "future_item_v9", id: "fi_1", text: "card 4012888888881881" }, + ], + { tools: [{ type: "custom", name: "send_mail", description: "escalate to help@acme.com" }], prompt: { id: "pmpt_1", variables: { customer: "bob@corp.io" } } }, + )); + text = rText(await res.json()); + sent = JSON.stringify(await calls()); + ok("responses/tools: upstream NEVER saw raw PII in any tool item, tool or variable", + !["john@acme.com", "jane@corp.io", "ops@acme.com", "4012888888881881", "help@acme.com", "bob@corp.io"].some((x) => sent.includes(x)), sent.slice(0, 300)); + ok("responses/tools: the echoed tool text is restored in the reply", + text.includes("john@acme.com") && text.includes("jane@corp.io") && text.includes("ops@acme.com") && !/<[A-Z_]+_[0-9A-F]+_\d+>/.test(text), text); + // ---- reversible (Responses): a prior assistant turn fed back as input ---- // The reply cordon restores carries real values, and a stateless client appends it to // the next request's input. Those parts are output_text/refusal, not input_text. @@ -170,7 +210,7 @@ const setTenant2 = setTenantOn(BASE2); sent = JSON.stringify(await calls()); ok("responses/strip: upstream saw [EMAIL], not raw", sent.includes("[EMAIL]") && !sent.includes("john@acme.com")); await reset(); - res = await post("/v1/responses", rBody(PII), { "x-tenant": "loose", "x-redact-mode": "off" }); + res = await post("/v1/responses", rBody(PII), { ...asKey("loose-key"), "x-redact-mode": "off" }); text = rText(await res.json()); ok("responses/off: reply echoes raw (nothing redacted)", text.includes("john@acme.com")); ok("responses/off: X-Redacted 0", res.headers.get("x-redacted") === "0"); @@ -199,14 +239,74 @@ const setTenant2 = setTenantOn(BASE2); ok("narrowing sets without permission: error names the dropped sets", /phi, secrets/.test(JSON.stringify(await res.json().catch(() => ({}))))); ok("narrowing sets without permission: upstream NOT called", (await calls()).total === 0); await reset(); - res = await post("/v1/messages", aBody(PII), { "x-tenant": "loose", "x-redact-sets": "pii,pci" }); + res = await post("/v1/messages", aBody(PII), { ...asKey("loose-key"), "x-redact-sets": "pii,pci" }); ok("valid sets: accepted for a tenant allowed to loosen (200)", res.status === 200, String(res.status)); - await setTenant({ tenant: "narrow", activeSets: ["pii"] }); + await setTenant({ tenant: keyTenant("narrow-key"), activeSets: ["pii"] }); await reset(); - res = await post("/v1/messages", aBody(PII), { "x-tenant": "narrow", "x-redact-sets": "pii,pci" }); + res = await post("/v1/messages", aBody(PII), { ...asKey("narrow-key"), "x-redact-sets": "pii,pci" }); ok("widening sets: always allowed (200)", res.status === 200, String(res.status)); ok("widening sets: the added set applies", /CREDIT_CARD/.test(res.headers.get("x-redacted-types") || ""), res.headers.get("x-redacted-types")); + // ---- X-Tenant: ignored by default, and when trusted it can only select a stricter policy ---- + // :8811 runs with TRUST_TENANT_HEADER unset. A strip tenant named in X-Tenant must not + // apply there: the reply comes back restored (reversible, the credential tenant's mode). + await setTenant2({ tenant: "strict", mode: "strip" }); + await reset(); + res = await post2("/v1/messages", aBody(PII), { "x-tenant": "strict" }); + text = (await res.json())?.content?.[0]?.text || ""; + ok("x-tenant default: header ignored (credential tenant's reversible mode applies)", + res.status === 200 && text.includes("john@acme.com") && !text.includes("[EMAIL]"), text); + await setTenant2({ tenant: "loose", allowHeaderOverride: true }); + await reset(); + res = await post2("/v1/messages", aBody(PII), { "x-tenant": "loose", "x-redact-mode": "off" }); + ok("x-tenant default: a loose tenant's override does not apply (403)", res.status === 403, String(res.status)); + ok("x-tenant default: upstream NOT called", (await calls()).total === 0); + // :8810 opts in (TRUST_TENANT_HEADER=true): a stricter tenant is honoured. + await reset(); + res = await post("/v1/messages", aBody(PII), { "x-tenant": "strict" }); + text = (await res.json())?.content?.[0]?.text || ""; + ok("x-tenant trusted: a stricter tenant's policy applies (strip)", res.status === 200 && text.includes("[EMAIL]") && !text.includes("john@acme.com"), text); + // ...and every looser selection is refused before the upstream is called. + await setTenant({ tenant: "hdr-loose", allowHeaderOverride: true }); + await setTenant({ tenant: "hdr-off", mode: "off" }); + await setTenant({ tenant: "hdr-narrow", activeSets: ["pii"] }); + await setTenant({ tenant: "hdr-open", failMode: "open" }); + await setTenant({ tenant: "hdr-nosystem", redactSystem: false }); + await setTenant({ tenant: "hdr-pseudo", consistentPseudonyms: true }); + await setTenant({ tenant: "hdr-eu", upstreamOverride: { anthropic: "http://127.0.0.1:1" } }); + for (const [tenant, knob] of [["hdr-loose", "allowHeaderOverride"], ["hdr-off", "mode"], ["hdr-narrow", "activeSets"], ["hdr-open", "failMode"], + ["hdr-nosystem", "redactSystem"], ["hdr-pseudo", "consistentPseudonyms"], ["hdr-eu", "upstreamOverride"]]) { + await reset(); + res = await post("/v1/messages", aBody(PII), { "x-tenant": tenant }); + const err = JSON.stringify(await res.json().catch(() => ({}))); + ok(`x-tenant trusted: looser ${knob} refused (403, names the knob)`, res.status === 403 && err.includes(knob), `${res.status} ${err}`); + ok(`x-tenant trusted: looser ${knob} never reaches upstream`, (await calls()).total === 0); + } + // A tenant no policy names resolves to the global defaults, the same as the caller's own. + await reset(); + res = await post("/v1/messages", aBody(PII), { "x-tenant": "unconfigured-team" }); + ok("x-tenant trusted: an unconfigured tenant (same defaults) is accepted", res.status === 200, String(res.status)); + + // ---- near-miss generation paths are redacted, not passed through verbatim ---- + 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 || ""; + sent = JSON.stringify(await calls()); + ok(`near-miss ${path}: upstream NEVER saw raw PII`, !sent.includes("john@acme.com") && !sent.includes("4012888888881881"), sent.slice(0, 200)); + ok(`near-miss ${path}: redacted (X-Redacted >= 2)`, Number(res.headers.get("x-redacted")) >= 2, res.headers.get("x-redacted")); + } + for (const path of ["/v1/x/../responses", "/v1/x/%2e%2e/responses", "/v1/./messages"]) { + await reset(); + res = await rawPost(path, path.endsWith("responses") ? rBody(PII) : aBody(PII)); + sent = JSON.stringify(await calls()); + ok(`near-miss ${path}: upstream NEVER saw raw PII`, !sent.includes("john@acme.com") && !sent.includes("4012888888881881"), sent.slice(0, 200)); + ok(`near-miss ${path}: redacted (X-Redacted >= 2)`, Number(res.headers["x-redacted"]) >= 2, String(res.headers["x-redacted"])); + } + await reset(); + res = await post("/v1/messages/count_tokens/", aBody("hello")); + ok("near-miss: a count_tokens sub-path still passes through", (await res.json())?.input_tokens === 42); + // ---- passthrough (count_tokens) ---- await reset(); res = await post("/v1/messages/count_tokens", aBody("hello")); @@ -226,10 +326,10 @@ const setTenant2 = setTenantOn(BASE2); ok("admin: valid activeSets accepted (200)", (await setTenant({ tenant: "t2", activeSets: ["pii", "pci"] })).status === 200); // ---- consistent pseudonyms via tenant policy ---- - await setTenant({ tenant: "acme", consistentPseudonyms: true, mode: "reversible" }); + await setTenant({ tenant: keyTenant("acme-key"), consistentPseudonyms: true, mode: "reversible" }); await reset(); - await post("/v1/messages", aBody("mail john@acme.com"), { "x-tenant": "acme" }); - await post("/v1/messages", aBody("again john@acme.com"), { "x-tenant": "acme" }); + await post("/v1/messages", aBody("mail john@acme.com"), asKey("acme-key")); + await post("/v1/messages", aBody("again john@acme.com"), asKey("acme-key")); { const bodies = (await calls()).bodies; const t1 = bodies[0]?.body?.messages?.[0]?.content?.match(//)?.[0]; @@ -241,8 +341,8 @@ const setTenant2 = setTenantOn(BASE2); // :8811 runs with an empty TENANT_SECRET and no ALLOW_WEAK_PSEUDONYM_SECRET escape hatch, // so a tenant that turns on consistentPseudonyms there can't mint guessable tokens. await reset(); - await setTenant2({ tenant: "leaky", consistentPseudonyms: true, mode: "reversible" }); - res = await post2("/v1/messages", aBody("mail john@acme.com"), { "x-tenant": "leaky" }); + await setTenant2({ tenant: keyTenant("leaky-key"), consistentPseudonyms: true, mode: "reversible" }); + res = await post2("/v1/messages", aBody("mail john@acme.com"), asKey("leaky-key")); ok("pseudonym-no-secret: fails closed (422)", res.status === 422, String(res.status)); ok("pseudonym-no-secret: upstream NOT called (PII never forwarded)", (await calls()).total === 0); ok("pseudonym-no-secret: error names the pseudonym-secret stage", @@ -253,8 +353,8 @@ const setTenant2 = setTenantOn(BASE2); ok("pseudonym-no-secret: non-pseudonym request still works (200)", res.status === 200, String(res.status)); // ---- data-residency upstream override ---- - await setTenant({ tenant: "eu", upstreamOverride: { anthropic: "http://127.0.0.1:1" } }); - res = await post("/v1/messages", aBody("hi jane@corp.io"), { "x-tenant": "eu" }); + await setTenant({ tenant: keyTenant("eu-key"), upstreamOverride: { anthropic: "http://127.0.0.1:1" } }); + res = await post("/v1/messages", aBody("hi jane@corp.io"), asKey("eu-key")); ok("residency: override routes away from stub (502)", res.status === 502, String(res.status)); { const j = await res.json().catch(() => ({})); diff --git a/_test_unit.mjs b/_test_unit.mjs index 5553e9f..64ee3f2 100644 --- a/_test_unit.mjs +++ b/_test_unit.mjs @@ -218,6 +218,90 @@ const noRaw = (body, raw) => !JSON.stringify(body).includes(raw); ok("stream: truncated trailing placeholder restored (not leaked)", out === "see john@acme.com", out); } +// ---------------- Responses API: every request shape reaches the detector ---------------- +{ + const RAW = "john.doe@acme.com"; + const rRedact = (body, v = new Vault("reversible"), redactSystem = true) => + applyRedaction({ model: "gpt-4o-mini", ...body }, "openai", v, ALL, detector, redactSystem, "responses"); + const cases = [ + ["function_call_output.output as input_text parts", + { input: [{ type: "function_call_output", call_id: "call_1", output: [{ type: "input_text", text: `owner ${RAW}` }] }] }], + ["custom_tool_call.input", + { input: [{ type: "custom_tool_call", call_id: "call_2", name: "send_mail", input: `to: ${RAW}` }] }], + ["custom_tool_call_output.output (string)", + { input: [{ type: "custom_tool_call_output", call_id: "call_2", output: `sent to ${RAW}` }] }], + ["custom_tool_call_output.output (input_text parts)", + { input: [{ type: "custom_tool_call_output", call_id: "call_2", output: [{ type: "input_text", text: `sent to ${RAW}` }] }] }], + ["local_shell_call_output.output", + { input: [{ type: "local_shell_call_output", id: "lsh_1", call_id: "call_3", output: `users.csv: ${RAW}` }] }], + ["custom tool description", + { input: "hi", tools: [{ type: "custom", name: "lookup", description: `escalations go to ${RAW}` }] }], + // The variable is deliberately called `name`: variable names are the caller's own + // keys, so the structural-key exemption must not apply to them. + ["prompt.variables string value", + { prompt: { id: "pmpt_1", variables: { name: RAW } } }], + ["prompt.variables input_text value", + { prompt: { id: "pmpt_1", variables: { customer: { type: "input_text", text: RAW } } } }], + ["an unknown future item type with a text field", + { input: [{ type: "future_item_v9", id: "fi_1", text: `note ${RAW}` }] }], + ]; + for (const [name, body] of cases) { + const { deidBody, spans } = rRedact(body); + ok(`responses: ${name} redacted`, noRaw(deidBody, RAW) && spans.some((s) => s.type === "EMAIL"), JSON.stringify(deidBody)); + } + + // Structural fields and media payloads reach the upstream byte-exact. + const struct = { + input: [ + { type: "function_call_output", call_id: "call_555-123-4567", output: [ + { type: "input_text", text: `owner ${RAW}` }, + { type: "input_image", image_url: "data:image/png;base64,QUtJQUlPU0ZPRE5ON0VYQU1QTEU=" }, + ] }, + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: `asked about ${RAW}` }], encrypted_content: "gAAAA555-123-4567" }, + { type: "future_item_v9", id: "fi_1", status: "completed", arguments: JSON.stringify({ to: RAW, qty: 3 }) }, + ], + }; + const { deidBody: sd } = rRedact(struct); + ok("responses: call_id, encrypted_content and input_image forwarded untouched", + sd.input[0].call_id === "call_555-123-4567" && sd.input[1].encrypted_content === "gAAAA555-123-4567" && + sd.input[0].output[1].image_url === struct.input[0].output[1].image_url, JSON.stringify(sd)); + ok("responses: reasoning summary text redacted", noRaw(sd.input[1], RAW), JSON.stringify(sd.input[1])); + { + let args; + try { args = JSON.parse(sd.input[2].arguments); } catch {} + ok("responses: an unknown item's arguments stay valid JSON with a placeholder", + /^ b.input && Array.isArray(b.input) ? b.input : []), tools: cases[5][1].tools, prompt: cases[6][1].prompt }; + const { deidBody } = rRedact(all, v); + const toks = [...new Set(JSON.stringify(deidBody).match(//g) ?? [])]; + const resp = { output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: `saw ${toks.join(" ")}` }] }] }; + const back = reidentifyBody(resp, "openai", v, "responses").output[0].content[0].text; + ok("responses: placeholders from the new shapes restore on the way back", + toks.length === 1 && back === `saw ${RAW}`, `${toks.join(",")} -> ${back}`); + } +} + // ---------------- Class 2: unicode / zero-width / full-width evasion ---------------- ok("Class2: zero-width email detected", types(runAll("mail john​@acme.com now", ["pii"])).includes("EMAIL")); ok("Class2: full-width card detected", types(runAll("card 4012888888881881", ["pci"])).includes("CREDIT_CARD")); diff --git a/docker-compose.yml b/docker-compose.yml index 9a5b303..5e627a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: POLICY_STORE: "${CORDON_POLICY_STORE:-}" ADMIN_TOKEN: "${CORDON_ADMIN_TOKEN:-}" TENANT_FROM_AUTH: "${CORDON_TENANT_FROM_AUTH:-true}" - TRUST_TENANT_HEADER: "${CORDON_TRUST_TENANT_HEADER:-true}" + TRUST_TENANT_HEADER: "${CORDON_TRUST_TENANT_HEADER:-false}" ALLOW_HEADER_OVERRIDE: "${CORDON_ALLOW_HEADER_OVERRIDE:-false}" UPSTREAM_TIMEOUT_MS: "${CORDON_UPSTREAM_TIMEOUT_MS:-600000}" OPENAI_BASE: "${CORDON_OPENAI_BASE:-https://api.openai.com}" diff --git a/docs/reference.md b/docs/reference.md index 2e14ae1..12233c1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -67,7 +67,7 @@ curl localhost:8080/admin/tenant -H 'x-admin-token: …' -H 'content-type: appli - **Consistent pseudonyms**: `` derived as `HMAC(TENANT_SECRET, value)`, so the same person maps to the same token across requests (the model can correlate) while the value is never stored. Requires a strong `TENANT_SECRET` (16+ chars); this mode fails closed without one. `ALLOW_WEAK_PSEUDONYM_SECRET=1` overrides for dev only. - **Data residency**: route a tenant to a regional upstream base. - **Durable policy**: `POLICY_STORE=./policies.json` persists tenant policy across restarts on the same volume as the audit log; unset keeps it in memory. -- **Tenant identity**: `X-Tenant: `, else derived from the API key. Set `TRUST_TENANT_HEADER=false` when callers are not trusted to pick their tenant; `X-Tenant` is then ignored and the tenant always comes from the API key. +- **Tenant identity**: derived from the API key (`TENANT_FROM_AUTH`, default on), else `public`. `X-Tenant` is ignored unless `TRUST_TENANT_HEADER=true`. When it is honoured it may only select a tenant whose policy is at least as strict as the caller's own on every knob: `mode` (`off` < `reversible` < `strip`), `activeSets` (a superset), `failMode` (`open` < `closed`), `redactSystem` (`false` < `true`), `consistentPseudonyms` (`true` < `false`, since a stable token lets the provider link a value across requests), `allowHeaderOverride` (`true` < `false`), and the same upstream bases (a residency route has no stricter direction). Anything else is refused with 403 before the upstream is called. A tenant that differs in a looser or residency setting is reached by giving its callers their own API key and setting the policy on the key-derived tenant (`auth:` plus the first 16 hex characters of the key's SHA-256). The header also sets the tenant name in the audit log and metrics, which is the trust the setting grants. - **Per-request headers can only tighten**: `X-Redact-Mode` must be at least as strong as the policy mode (`off` < `reversible` < `strip`) and `X-Redact-Sets` must include every policy set. Anything weaker is refused with 403 before the upstream is called. `"allowHeaderOverride": true` on a tenant (or `ALLOW_HEADER_OVERRIDE=true` globally) restores per-request loosening. ## Ops endpoints diff --git a/fuzz/redact_leak.fuzz.ts b/fuzz/redact_leak.fuzz.ts index 79c9b0a..548b517 100644 --- a/fuzz/redact_leak.fuzz.ts +++ b/fuzz/redact_leak.fuzz.ts @@ -24,7 +24,7 @@ import { detector } from '../src/detect/index'; import { applyRedaction } from '../src/redact/apply'; import { reidentifyBody } from '../src/redact/reidentify'; import { Vault } from '../src/redact/vault'; -import type { Provider, RedactMode, RedactSet } from '../src/types'; +import type { Dialect, Provider, RedactMode, RedactSet } from '../src/types'; const ALL_SETS: RedactSet[] = ['pii', 'phi', 'pci', 'secrets']; const MODES: RedactMode[] = ['reversible', 'strip']; @@ -59,17 +59,57 @@ function countOccurrences(hay: string, needle: string): number { return n; } +/** + * A Responses API body carrying the fuzzed text in every input shape the walk reads: + * message content (string and part array, including a prior assistant turn), tool calls + * and their outputs for each tool kind (string and part-array outputs), an item type the + * walk has no special case for, tool descriptions, and prompt variables. The walk is + * fail-closed over item types, so the unknown item must come out as redacted as the rest. + */ +function responsesBody(a: string, b: string, c: string): any { + return { + model: 'gpt-4o', + instructions: b, + input: [ + { role: 'user', content: a }, + { + role: 'user', + content: [ + { type: 'input_text', text: b }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + ], + }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: c, annotations: [] }] }, + { type: 'function_call', call_id: 'call_1', name: 'f', arguments: JSON.stringify({ q: a, n: c }) }, + { type: 'function_call_output', call_id: 'call_1', output: [{ type: 'input_text', text: b }] }, + { type: 'custom_tool_call', call_id: 'call_2', name: 'g', input: c }, + { type: 'custom_tool_call_output', call_id: 'call_2', output: a }, + { type: 'local_shell_call_output', call_id: 'call_3', output: b }, + { type: 'future_item', id: 'fi_1', payload: { note: c, list: [a] } }, + ], + tools: [ + { type: 'function', name: 'f', description: b, parameters: { example: c } }, + { type: 'custom', name: 'g', description: a }, + ], + prompt: { id: 'pmpt_1', variables: { v: a, w: { type: 'input_text', text: c } } }, + }; +} + export function fuzz(data: Buffer): void { const text = data.toString('utf8'); const sel = data.length ? data[0] : 0; const provider: Provider = sel & 1 ? 'openai' : 'anthropic'; const mode = MODES[(sel >> 1) % MODES.length]; + // Bit 2 sends an OpenAI input through the Responses API walk instead of chat.completions. + const dialect: Dialect = provider === 'anthropic' ? 'messages' : sel & 4 ? 'responses' : 'chat'; const [a, b = '', c = ''] = fields(text); // Exercise the structured, model-visible fields as well as plain content — // tool arguments and tool schemas carry user data and are redacted too. const body: any = - provider === 'anthropic' + dialect === 'responses' + ? responsesBody(a, b, c) + : provider === 'anthropic' ? { model: 'claude-haiku-4-5', system: b, @@ -104,13 +144,47 @@ export function fuzz(data: Buffer): void { const beforeText = allLeafText(body).join('\u0000'); const vault = new Vault(mode); - const { deidBody, spans } = applyRedaction(body, provider, vault, ALL_SETS, detector, true); + const { deidBody, spans } = applyRedaction(body, provider, vault, ALL_SETS, detector, true, dialect); // A hostile key name must not have reached Object.prototype. if (({} as any).polluted !== undefined || (Object.prototype as any).polluted !== undefined) { throw new Error('prototype pollution via a redacted leaf key'); } + // Walk coverage (Responses). The occurrence budget below only covers values the + // detector claimed, so a field the walk never read would pass it. Detection is per + // field and deterministic, and the vault maps a value to one token, so every field + // carrying the same fuzzed text must come out exactly like the message field holding + // that text. A skipped field still holds the raw text and fails this. + if (dialect === 'responses') { + const d = deidBody; + const [A, B, C] = [d.input[0].content, d.input[1].content[0].text, d.input[2].content[0].text]; + let args: any; + try { + args = JSON.parse(d.input[3].arguments); + } catch { + throw new Error('function_call.arguments is no longer valid JSON after redaction'); + } + const fieldsOf: Array<[string, unknown, string]> = [ + ['instructions', d.instructions, B], + ['function_call.arguments.q', args.q, A], + ['function_call.arguments.n', args.n, C], + ['function_call_output.output[0].text', d.input[4].output[0].text, B], + ['custom_tool_call.input', d.input[5].input, C], + ['custom_tool_call_output.output', d.input[6].output, A], + ['local_shell_call_output.output', d.input[7].output, B], + ['future_item.payload.note', d.input[8].payload.note, C], + ['future_item.payload.list[0]', d.input[8].payload.list[0], A], + ['tools[0].description', d.tools[0].description, B], + ['tools[0].parameters.example', d.tools[0].parameters.example, C], + ['tools[1].description', d.tools[1].description, A], + ['prompt.variables.v', d.prompt.variables.v, A], + ['prompt.variables.w.text', d.prompt.variables.w.text, C], + ]; + for (const [label, got, want] of fieldsOf) + if (got !== want) throw new Error(`Responses ${label} was not de-identified like the message text it copies (mode=${mode})`); + } + if (!spans.length) return; // nothing was detected — nothing to leak // THE guarantee, as an occurrence count. @@ -138,7 +212,7 @@ export function fuzz(data: Buffer): void { `redacted value survived into the de-identified body: ${survived} occurrence(s) ` + `remain but at most ${budget} allowed (${n} claimed, type=${ spans.find((s) => s.value === value)?.type - }, mode=${mode}, provider=${provider})`, + }, mode=${mode}, provider=${provider}, dialect=${dialect})`, ); } } @@ -156,12 +230,16 @@ export function fuzz(data: Buffer): void { const placeholders = spans.map((s) => vault.placeholderFor(s.value, s.type)); const echoed = placeholders.join(' '); const response = - provider === 'anthropic' + dialect === 'responses' + ? { output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: echoed }] }] } + : provider === 'anthropic' ? { content: [{ type: 'text', text: echoed }] } : { choices: [{ message: { role: 'assistant', content: echoed } }] }; - const restoredBody: any = reidentifyBody(response, provider, vault); + const restoredBody: any = reidentifyBody(response, provider, vault, dialect); const restored: string = - provider === 'anthropic' + dialect === 'responses' + ? restoredBody.output[0].content[0].text + : provider === 'anthropic' ? restoredBody.content[0].text : restoredBody.choices[0].message.content; for (const placeholder of placeholders) { diff --git a/fuzz/seeds/redact_leak/responses_reversible b/fuzz/seeds/redact_leak/responses_reversible new file mode 100644 index 0000000000000000000000000000000000000000..f1f531e668282576e7348db8e371d6ad2b72c605 GIT binary patch literal 126 zcmZQg%}vbAQOL^A$a6?c&P~-z&d*gSN>xZsEJ{%@F)%dJwEzMLVQ67t$dH_vlcQj2 zYN~5!WUOmqYG$rbT%4x>;pm!~TUavWm*=GxDP$$)r8*?%7ZvDb<||~URw_7pdpdgh T2fO*Z`1zT;MmYKg__zW9=yfCF literal 0 HcmV?d00001 diff --git a/fuzz/seeds/redact_leak/responses_strip b/fuzz/seeds/redact_leak/responses_strip new file mode 100644 index 0000000000000000000000000000000000000000..7a4117759739a7ee544f222c764a50f75e071497 GIT binary patch literal 126 zcmZQi%}vbAQOL^A$a6?c&P~-z&d*gSN>xZsEJ{%@F)%dJwEzMLVQ67t$dH_vlcQj2 zYN~5!WUOmqYG$rbT%4x>;pm!~TUavWm*=GxDP$$)r8*?%7ZvDb<||~URw_7pdpdgh T2fO*Z`1zT;MmYKg__zW9>2V|E literal 0 HcmV?d00001 diff --git a/src/config.ts b/src/config.ts index 65319ba..093fcbc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,9 +72,11 @@ export const config = { // `allowHeaderOverride`. allowHeaderOverride: (env.ALLOW_HEADER_OVERRIDE ?? "false") === "true", - // Honour the caller's X-Tenant header. false = ignore it and derive the tenant from the - // API key (TENANT_FROM_AUTH), so a caller can't pick another tenant's policy. - trustTenantHeader: (env.TRUST_TENANT_HEADER ?? "true") === "true", + // Honour the caller's X-Tenant header. Off unless set to "true": the tenant then comes + // from the API key (TENANT_FROM_AUTH), so a caller can't pick another tenant's policy. + // When on, X-Tenant may still only select a policy at least as strict as the caller's + // own (providers.tenantSelectionViolation). + trustTenantHeader: (env.TRUST_TENANT_HEADER ?? "false") === "true", // When no X-Tenant is sent, derive the tenant from the API key so different // callers get isolated policy / pseudonym namespaces. diff --git a/src/index.ts b/src/index.ts index 819dd7a..4fdea77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,13 @@ import { createHash, timingSafeEqual } from "node:crypto"; import Fastify from "fastify"; -import { normalize, passthroughBase, forwardVerbatim, headerOverrideViolation } from "./providers"; +import { + normalize, + passthroughBase, + forwardVerbatim, + headerOverrideViolation, + tenantSelectionViolation, + generationRoute, +} from "./providers"; import { handle, safeUpstreamError } from "./proxy"; import { config, pseudonymSecretGuard, adequatePseudonymSecret, allowWeakPseudonymSecret } from "./config"; import { metrics } from "./metrics"; @@ -147,8 +154,10 @@ app.post("/v1/*", async (req, reply) => { const { headers, bare, path } = reqParts(req); // Only the three generation endpoints are redacted; everything else (count_tokens, - // embeddings, …) forwards verbatim so it is never normalize-mangled. - if (bare !== "/v1/chat/completions" && bare !== "/v1/responses" && bare !== "/v1/messages") { + // embeddings, …) forwards verbatim so it is never normalize-mangled. A spelling the + // upstream can still resolve to a generation endpoint (trailing slash, dot segments, + // case, percent-escapes) counts as one: see generationRoute. + if (!generationRoute(bare)) { return passthroughUnknown(req, reply, "POST"); } @@ -172,6 +181,14 @@ app.post("/v1/*", async (req, reply) => { } } + // A trusted X-Tenant may only select a policy at least as strict as the caller's own. + // Checked before the header override below, which reads the policy of the selected tenant. + const tenantLoosens = tenantSelectionViolation(headers); + if (tenantLoosens) { + reply.code(403); + return { error: `${config.brand}: ${tenantLoosens}` }; + } + // Policy is the floor: a caller header may tighten redaction, never loosen it (unless the // operator allowed header override). Refused pre-hijack, upstream never called. const loosens = headerOverrideViolation(headers); diff --git a/src/providers.ts b/src/providers.ts index c31f825..d989c23 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -175,22 +175,49 @@ function resolveSets(headers: Record, tenant: string): RedactSet return getPolicy(tenant).activeSets ?? config.activeSets; } +/** The three redacted generation endpoints, keyed by canonical path. */ +const GENERATION_ROUTES = new Map([ + ["/v1/chat/completions", ["openai", "chat"]], + ["/v1/responses", ["openai", "responses"]], + ["/v1/messages", ["anthropic", "messages"]], +]); + +/** + * The generation endpoint a request path can reach upstream, or null for any other path. + * The path is forwarded as the caller sent it, and the upstream does not read those bytes + * literally: fetch resolves "." and ".." segments (%2e forms too) and treats "\" as "/", + * and a server may decode percent-escapes, ignore case, or drop a trailing or doubled + * slash. Any of those can turn a spelling that is not literally a generation endpoint + * (`/v1/chat/completions/`) into one, so the path is classified on the most lenient of + * those readings; classifying on the literal bytes sent such a request down the verbatim + * passthrough with its body unread. A sub-path (/v1/messages/count_tokens) is still not a + * generation endpoint. + */ +export function generationRoute(bare: string): [Provider, Dialect] | null { + let p = bare; + try { + p = decodeURIComponent(p); + } catch { + /* a malformed escape: classify the undecoded bytes */ + } + const segs: string[] = []; + for (const s of p.toLowerCase().split(/[\\/]+/)) { + if (s === "" || s === ".") continue; + if (s === "..") segs.pop(); + else segs.push(s); + } + return GENERATION_ROUTES.get("/" + segs.join("/")) ?? null; +} + export function normalize( path: string, body: any, headers: Record, ): CanonicalRequest | null { - // EXACT endpoint match — sub-paths (e.g. /v1/messages/count_tokens) are NOT - // generation requests and must take the transparent-passthrough route instead. + // Sub-paths (e.g. /v1/messages/count_tokens) are NOT generation requests and take the + // transparent-passthrough route instead; see generationRoute for what counts as a match. const bare = path.split("?")[0]; - const route: [Provider, Dialect] | null = - bare === "/v1/chat/completions" - ? ["openai", "chat"] - : bare === "/v1/responses" - ? ["openai", "responses"] - : bare === "/v1/messages" - ? ["anthropic", "messages"] - : null; + const route = generationRoute(bare); if (!route || !body || typeof body !== "object") return null; const [provider, dialect] = route; @@ -220,10 +247,9 @@ export function authHeaders(headers: Record): Record): string { - if (config.trustTenantHeader && headers["x-tenant"]) return headers["x-tenant"]; +/** The tenant a request's credentials alone give it: derived from the API key + * (TENANT_FROM_AUTH), else "public". */ +function credentialTenant(headers: Record): string { if (config.tenantFromAuth) { const auth = headers["authorization"] || headers["x-api-key"] || ""; if (auth) return "auth:" + sha256(auth).slice(0, 16); @@ -231,6 +257,60 @@ export function resolveTenant(headers: Record): string { return "public"; } +/** Tenant resolution: X-Tenant only when TRUST_TENANT_HEADER is on (index.ts refuses a + * selection tenantSelectionViolation rejects before this is used), else the credential + * tenant. */ +export function resolveTenant(headers: Record): string { + if (config.trustTenantHeader && headers["x-tenant"]) return headers["x-tenant"]; + return credentialTenant(headers); +} + +/** Every policy knob a tenant resolves to, global config filling the unset ones. */ +function effectivePolicy(tenant: string) { + const p = getPolicy(tenant); + return { + mode: p.mode && VALID_MODES.has(p.mode) ? p.mode : config.defaultMode, + activeSets: p.activeSets ?? config.activeSets, + failMode: p.failMode ?? config.failMode, + redactSystem: p.redactSystem ?? config.redactSystem, + consistentPseudonyms: p.consistentPseudonyms ?? config.consistentPseudonyms, + allowHeaderOverride: p.allowHeaderOverride ?? config.allowHeaderOverride, + }; +} + +/** + * With TRUST_TENANT_HEADER on, X-Tenant may only select a policy at least as strict as the + * one the caller's credentials already give it, on every knob: + * mode off < reversible < strip + * activeSets must include every set of the credential tenant + * failMode open < closed + * redactSystem false < true + * consistentPseudonyms true < false (a stable token lets the upstream link one value + * across requests; a per-request token does not) + * allowHeaderOverride true < false + * upstream bases must be identical: a residency route has no stricter direction + * Returns why the selection loosens policy, or null. The header still picks the audit and + * metrics label freely; that is the trust the operator grants by turning it on. + */ +export function tenantSelectionViolation(headers: Record): string | null { + const asked = headers["x-tenant"]; + if (!config.trustTenantHeader || !asked) return null; + const home = credentialTenant(headers); + if (asked === home) return null; + const a = effectivePolicy(asked); + const h = effectivePolicy(home); + const looser: string[] = []; + if (MODE_STRENGTH[a.mode] < MODE_STRENGTH[h.mode]) looser.push("mode"); + if (h.activeSets.some((s) => !a.activeSets.includes(s))) looser.push("activeSets"); + if (a.failMode === "open" && h.failMode !== "open") looser.push("failMode"); + if (!a.redactSystem && h.redactSystem) looser.push("redactSystem"); + if (a.consistentPseudonyms && !h.consistentPseudonyms) looser.push("consistentPseudonyms"); + if (a.allowHeaderOverride && !h.allowHeaderOverride) looser.push("allowHeaderOverride"); + if (baseFor("openai", asked) !== baseFor("openai", home) || baseFor("anthropic", asked) !== baseFor("anthropic", home)) + looser.push("upstreamOverride"); + return looser.length ? `X-Tenant selects a policy that is not at least as strict as this caller's own (${looser.join(", ")})` : null; +} + /** Upstream base for a provider, honouring a per-tenant data-residency override. */ export function baseFor(provider: Provider, tenant?: string): string { if (tenant) { diff --git a/src/redact/apply.ts b/src/redact/apply.ts index 38bcd49..f6802c5 100644 --- a/src/redact/apply.ts +++ b/src/redact/apply.ts @@ -63,15 +63,63 @@ function pushJsonString(obj: any, key: string, slots: Slot[], finalizers: (() => } } +// Keys the Responses input walk never redacts, at any depth. The walk is fail-closed (every +// other string leaf of an input item is redacted, whatever the item type), so a key belongs +// here only when its value must reach the upstream byte-exact AND is not text the model +// reads as content: +// type, role, status enums the upstream validates against a fixed set +// id, call_id, references the upstream resolves by exact match to an earlier +// approval_request_id item or call; a rewritten id points at nothing +// name, model tool and model identifiers matched against the request +// encrypted_content an opaque reasoning blob only the upstream can decrypt +// image_url, file_id, media references and base64 payloads, the same fields an +// file_url, file_data input_image / input_file part carries (those parts are skipped) +// A caller-named key (a prompt variable, a JSON key inside arguments) is never matched +// against this list. +const RESPONSES_STRUCTURAL_KEYS = new Set([ + "type", "role", "status", "id", "call_id", "approval_request_id", "name", "model", + "encrypted_content", "image_url", "file_id", "file_url", "file_data", +]); + +// Objects the Responses walk skips whole: media parts (image pixels and file bytes are not +// text) and a generated image fed back as input (its `result` is base64 image data). +const RESPONSES_MEDIA_TYPES = new Set(["input_image", "input_file", "image_generation_call"]); + +/** + * Push a slot for every non-structural string leaf (and numeric leaf) of a Responses + * input item, content part or prompt variable. This walk has no allow-list of item + * types: an item type added to the API after this code was written is redacted the same + * way as a known one, because an unread field would otherwise reach the model raw. + * `arguments` is a JSON string in every item that carries it (function_call, mcp_call, + * mcp_approval_request), so it is parsed and redacted leaf-wise to stay valid JSON. + * Past MAX_LEAF_DEPTH the walk throws rather than stop: stopping would forward the + * deeper leaves unread, and a throw is a fail-closed 422 in the proxy. + */ +function pushResponsesLeaves(node: any, slots: Slot[], finalizers: (() => void)[], depth = 0): void { + if (depth > MAX_LEAF_DEPTH) + throw new Error(`Responses input nested deeper than ${MAX_LEAF_DEPTH} levels; refusing to forward unread fields`); + if (!Array.isArray(node) && RESPONSES_MEDIA_TYPES.has(node.type)) return; + const keys = Array.isArray(node) ? node.map((_: any, i: number) => i) : Object.keys(node); + for (const k of keys) { + if (typeof k === "string" && RESPONSES_STRUCTURAL_KEYS.has(k)) continue; + const v = node[k]; + if (typeof v === "string") { + if (k === "arguments") pushJsonString(node, k, slots, finalizers); + else slots.push(slot(node, k)); + } else if (typeof v === "number" || typeof v === "bigint") slots.push(numSlot(node, k)); + else if (v && typeof v === "object") pushResponsesLeaves(v, slots, finalizers, depth + 1); + } +} + /** * Collect every REDACTABLE text field in a provider REQUEST body. Walks message * content (string or content-part array), Anthropic system blocks + tool_result - * content, Responses `instructions` + `input` items, AND every model-visible - * structured field that can carry user data: OpenAI message `name` + assistant - * `tool_calls[].function.arguments`, Responses `function_call` arguments and - * `function_call_output` output, tool definitions (descriptions + parameter - * schemas), and Anthropic `tool_use` inputs. Only image / file parts and raw - * provider-auth headers are intentionally left untouched. + * content, Responses `instructions`, `input` items and `prompt.variables`, AND every + * model-visible structured field that can carry user data: OpenAI message `name` + + * assistant `tool_calls[].function.arguments`, tool definitions (descriptions + + * parameter schemas), and Anthropic `tool_use` inputs. Responses input items are walked + * fail-closed (see pushResponsesLeaves). Only image / file parts, the structural + * Responses keys above and raw provider-auth headers are intentionally left untouched. */ function requestTextSlots( body: any, @@ -127,25 +175,38 @@ function requestTextSlots( if (t && t.input_schema && typeof t.input_schema === "object") pushStringLeaves(t.input_schema, slots); } } else if (dialect === "responses") { - // Responses: `instructions` is the system prompt; tools are flat function objects. + // Responses: `instructions` is the system prompt; tools are flat objects. if (redactSystem && typeof body.instructions === "string") slots.push(slot(body, "instructions")); if (Array.isArray(body.tools)) for (const t of body.tools) { - if (t?.type !== "function") continue; + if (!t || typeof t !== "object") continue; + // A description is model-visible prose on every tool type that has one (function, + // custom); an MCP server's description is shown to the model the same way. Other + // tool fields are config the upstream acts on (MCP auth headers, vector store ids, + // a custom tool's grammar) and are left as sent. if (typeof t.description === "string") slots.push(slot(t, "description")); - if (t.parameters && typeof t.parameters === "object") pushStringLeaves(t.parameters, slots); + if (typeof t.server_description === "string") slots.push(slot(t, "server_description")); + if (t.type === "function" && t.parameters && typeof t.parameters === "object") pushStringLeaves(t.parameters, slots); + } + // A stored prompt's variables are substituted into the prompt the model reads. The + // variable names are the caller's own keys, so each value is taken as content. + const vars = body.prompt?.variables; + if (vars && typeof vars === "object") + for (const k of Object.keys(vars)) { + if (typeof vars[k] === "string") slots.push(slot(vars, k)); + else if (vars[k] && typeof vars[k] === "object") pushResponsesLeaves(vars[k], slots, finalizers); } - // `input` is a string, or a list of items: messages (string or part-array content), - // assistant function calls (JSON-string arguments) and their outputs. + // `input` is a string, or a list of items. Every item is walked fail-closed: messages, + // tool calls and tool outputs (string or part-array `output`) of every tool kind, and + // any item type this code does not know. if (typeof body.input === "string") slots.push(slot(body, "input")); else if (Array.isArray(body.input)) - for (const item of body.input) { + for (let i = 0; i < body.input.length; i++) { + const item = body.input[i]; + if (typeof item === "string") { slots.push(slot(body.input, i)); continue; } if (!item || typeof item !== "object") continue; if (!redactSystem && (item.role === "system" || item.role === "developer")) continue; - if (typeof item.content === "string" || Array.isArray(item.content)) pushContent(item, "content"); - if (item.type === "function_call" && typeof item.arguments === "string") - pushJsonString(item, "arguments", slots, finalizers); - if (item.type === "function_call_output" && typeof item.output === "string") slots.push(slot(item, "output")); + pushResponsesLeaves(item, slots, finalizers); } return { slots, finalizers }; } else {