fix(security): decouple request PII redaction from injection mode (#8102)

* fix(security): decouple request PII redaction from injection mode

PII_REDACTION_ENABLED now rewrites request PII independently of
INPUT_SANITIZER_MODE, so the enterprise recipe (MODE=block + PII on)
actually redacts. Also cover Responses API string input/prompt shapes
and correct docs that claimed MODE=redact strips injection text.

Refs: #8092 #8093 #8094 #8096 #8097

* test(security): drop no-explicit-any in sanitizer unit tests

Unblocks CI lint/quality ratchet on the PII redaction PR by typing
chat-like payloads instead of casting to any.

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
This commit is contained in:
Ravi Tharuma
2026-07-22 22:38:20 +02:00
committed by GitHub
parent ffb37f99a5
commit 7ae17168db
9 changed files with 253 additions and 48 deletions

View File

@@ -353,7 +353,7 @@ ALLOW_API_KEY_REVEAL=false
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII)
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn

View File

@@ -91,7 +91,7 @@ Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block | redact
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
```
### 🔒 PII Redaction
@@ -108,7 +108,8 @@ Automatic detection and optional redaction of personally identifiable informatio
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env
PII_REDACTION_ENABLED=true
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
```
### 🌐 Network Security

View File

@@ -218,9 +218,9 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| Variable | Default | Source File | Description |
| ------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- |
| `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | Enable scanning of incoming messages for prompt injection patterns. |
| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = log only, `block` = reject request with 400, `redact` = strip suspicious patterns. |
| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | Injection policy: `warn` = log only, `block` = reject request with 400. Legacy `redact` does **not** strip injection text; use `PII_REDACTION_ENABLED` for request PII rewrite. |
| `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Legacy alias for `INPUT_SANITIZER_MODE` — same behavior. |
| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Detect PII (emails, phones, SSNs) in incoming requests. |
| `PII_REDACTION_ENABLED` | `false` | `src/lib/guardrails/piiMasker.ts` | When `true`, redact PII in incoming requests (independent of injection mode). |
| `CREDENTIAL_REDACTION_ENABLED` | `false` | `src/lib/guardrails/credentialMasker.ts` | Redact well-known API-key / secret-token patterns from request/response payloads. Opt-in; mirrors `PII_REDACTION_ENABLED`. |
### Response-Side: PII Sanitizer
@@ -240,7 +240,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| Scenario | Configuration |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` |
| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` (injection blocks + request/response PII redaction; modes are independent) |
| **Monitoring only** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — logs but never blocks |
| **Personal use** | Leave all disabled — zero overhead |

View File

@@ -56,7 +56,7 @@ used when neither a DB override nor an environment variable is present.
| `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. |
| `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. |
| `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. |
| `PII_REDACTION_ENABLED` | boolean | `false` | Redact personally identifiable information from requests. |
| `PII_REDACTION_ENABLED` | boolean | `false` | Redact PII from requests (independent of `INPUT_SANITIZER_MODE`). |
| `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. |
| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. |

View File

@@ -60,12 +60,13 @@ exposes a `deps` constructor option so tests can inject fake `getSettings` and
Runs on **both** stages.
- **`preCall`** clones the payload, walks `system`, `messages`, and `input`
arrays, and applies `processPII()` (from `@/shared/utils/inputSanitizer`) to
string `content`/`text` fields. When `PII_REDACTION_ENABLED=true` **and**
`INPUT_SANITIZER_MODE=redact`, detected PII is stripped/redacted in the
outbound payload. Otherwise the call records detection counts without
rewriting content.
- **`preCall`** clones the payload, walks `system`, `messages`, `input`, and
`prompt` (including plain string items), and applies `processPII()` (from
`@/shared/utils/inputSanitizer`) to string `content`/`text` fields. When
`PII_REDACTION_ENABLED=true`, detected PII is redacted in the outbound
payload. This is independent of `INPUT_SANITIZER_MODE` (which only controls
prompt-injection policy). When redaction is off, the call records detection
counts without rewriting content.
- **`postCall`** deep-clones the response, runs `sanitizePIIResponse()` plus
the Responses-API-shape masker (`maskResponsesOutput` — covers
`output_text` and `output[].content[].text`). If any redaction occurs, the
@@ -83,7 +84,7 @@ options:
| Setting | Env var | Default | Effect |
| --------------- | ----------------------------------------------- | ------- | --------------------------------------- |
| Enabled | `INPUT_SANITIZER_ENABLED` | `true` | When `false`, guardrail short-circuits. |
| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | `block`, `warn`, or `log`. |
| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | Injection policy: `block`, `warn`, or `log`. (`redact` is accepted for back-compat but does **not** strip injection text; request PII rewrite is controlled by `PII_REDACTION_ENABLED`.) |
| Block threshold | `blockThreshold` option | `high` | Minimum severity required to block. |
**Mode precedence** (`getMode`): caller `options.mode`
@@ -223,9 +224,9 @@ Environment variables read by the built-in guardrails:
| Variable | Used by | Effect |
| ------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ |
| `INPUT_SANITIZER_ENABLED` | `prompt-injection` | Set `false` to disable detection entirely. |
| `INPUT_SANITIZER_MODE` | `prompt-injection`, `pii-masker` | Shared mode: `warn`, `block`, `log`, or `redact`. |
| `INPUT_SANITIZER_MODE` | `prompt-injection` | Injection policy: `warn`, `block`, or `log`. Legacy value `redact` does not rewrite injection text. |
| `INJECTION_GUARD_MODE` | `prompt-injection` | Mode for the injection guard; also a DB feature flag that **overrides** the env vars (DB > ENV). |
| `PII_REDACTION_ENABLED` | `pii-masker` | When `true` + mode `redact`, request PII is stripped. |
| `PII_REDACTION_ENABLED` | `pii-masker` | When `true`, request PII is redacted (independent of injection mode). |
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
The Vision Bridge reads runtime config from the DB-backed settings store

View File

@@ -10,9 +10,9 @@ type PiiDetection = {
type JsonRecord = Record<string, unknown>;
function isRequestPiiMaskingEnabled() {
return (
process.env.PII_REDACTION_ENABLED === "true" && process.env.INPUT_SANITIZER_MODE === "redact"
);
// Request PII redaction is controlled solely by PII_REDACTION_ENABLED.
// INPUT_SANITIZER_MODE only governs prompt-injection policy (warn/block/log).
return process.env.PII_REDACTION_ENABLED === "true";
}
function sanitizeStringValue(text: string) {
@@ -84,6 +84,13 @@ function cloneAndMaskRequestPayload(payload: unknown) {
const sanitizeMessageLikeList = (list: unknown) => {
if (!Array.isArray(list)) return list;
return list.map((entry) => {
// Responses API can pass plain strings in input[]
if (typeof entry === "string") {
const result = sanitizeStringValue(entry);
detections.push(...result.detections);
modified ||= result.modified;
return result.text;
}
if (!entry || typeof entry !== "object") return entry;
const record = { ...(entry as JsonRecord) };
if ("content" in record) {
@@ -116,6 +123,20 @@ function cloneAndMaskRequestPayload(payload: unknown) {
if (Array.isArray(clonedPayload.input)) {
clonedPayload.input = sanitizeMessageLikeList(clonedPayload.input);
} else if (typeof clonedPayload.input === "string") {
const result = sanitizeStringValue(clonedPayload.input);
detections.push(...result.detections);
modified ||= result.modified;
clonedPayload.input = result.text;
}
if (typeof clonedPayload.prompt === "string") {
const result = sanitizeStringValue(clonedPayload.prompt);
detections.push(...result.detections);
modified ||= result.modified;
clonedPayload.prompt = result.text;
} else if (Array.isArray(clonedPayload.prompt)) {
clonedPayload.prompt = sanitizeMessageLikeList(clonedPayload.prompt);
}
return {

View File

@@ -278,8 +278,10 @@ export function sanitizeRequest(body, logger = console) {
}
// ── PII Detection / Redaction ──
// PII rewrite is controlled by PII_REDACTION_ENABLED only.
// INPUT_SANITIZER_MODE is reserved for prompt-injection policy (warn/block/log).
if (config.piiRedaction) {
const piiResult = processPII(fullText, config.mode === "redact");
const piiResult = processPII(fullText, true);
result.piiDetections = piiResult.detections;
if (piiResult.detections.length > 0) {
@@ -287,11 +289,9 @@ export function sanitizeRequest(body, logger = console) {
`[SANITIZER] PII detected: ${piiResult.detections.map((d) => `${d.type}(${d.count})`).join(", ")}`
);
if (config.mode === "redact") {
// Deep clone and replace message contents with redacted versions
result.sanitizedBody = redactBody(body);
result.modified = true;
}
// Deep clone and replace message contents with redacted versions
result.sanitizedBody = redactBody(body);
result.modified = true;
}
}
@@ -304,36 +304,90 @@ export function sanitizeRequest(body, logger = console) {
* @returns {Object}
*/
function redactBody(body) {
// Deep clone to avoid mutating original
const clone = JSON.parse(JSON.stringify(body));
const messageSource = clone.messages !== undefined ? clone.messages : clone.input;
const messages = Array.isArray(messageSource)
? messageSource
: messageSource && typeof messageSource === "object"
? [messageSource]
: [];
: messageSource === undefined || messageSource === null
? []
: [messageSource];
for (const msg of messages) {
if (typeof msg.content === "string") {
msg.content = processPII(msg.content, true).text;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (typeof part === "string") {
const idx = msg.content.indexOf(part);
msg.content[idx] = processPII(part, true).text;
} else if (part.text) {
part.text = processPII(part.text, true).text;
}
}
const redactContentValue = (value) => {
if (typeof value === "string") {
return processPII(value, true).text;
}
if (Array.isArray(value)) {
return value.map((part) => {
if (typeof part === "string") {
return processPII(part, true).text;
}
if (part && typeof part === "object") {
const next = { ...part };
if (typeof next.text === "string") {
next.text = processPII(next.text, true).text;
}
if (typeof next.content === "string") {
next.content = processPII(next.content, true).text;
}
return next;
}
return part;
});
}
return value;
};
const redactedMessages = messages.map((msg) => {
if (typeof msg === "string") {
return processPII(msg, true).text;
}
if (!msg || typeof msg !== "object") {
return msg;
}
const next = { ...msg };
if ("content" in next) {
next.content = redactContentValue(next.content);
}
if (typeof next.text === "string") {
next.text = processPII(next.text, true).text;
}
return next;
});
if (clone.messages !== undefined) {
clone.messages = Array.isArray(clone.messages) ? redactedMessages : redactedMessages[0];
} else if (clone.input !== undefined) {
clone.input = Array.isArray(clone.input) ? redactedMessages : redactedMessages[0];
}
if (typeof clone.system === "string") {
clone.system = processPII(clone.system, true).text;
} else if (Array.isArray(clone.system)) {
clone.system = clone.system.map((entry) => {
if (typeof entry === "string") return processPII(entry, true).text;
if (entry && typeof entry === "object") {
const next = { ...entry };
if (typeof next.text === "string") next.text = processPII(next.text, true).text;
if (typeof next.content === "string") next.content = processPII(next.content, true).text;
return next;
}
return entry;
});
}
if (typeof clone.input === "string") {
clone.input = processPII(clone.input, true).text;
}
if (typeof clone.prompt === "string") {
clone.prompt = processPII(clone.prompt, true).text;
} else if (Array.isArray(clone.prompt)) {
clone.prompt = clone.prompt.map((entry) =>
typeof entry === "string" ? processPII(entry, true).text : entry
);
}
return clone;
}
// ─── Exports for Testing ──────────────────────────────────────────────
export { detectInjection, processPII, extractMessageContents, INJECTION_PATTERNS, PII_PATTERNS };

View File

@@ -119,26 +119,49 @@ test("prompt injection guardrail blocks suspicious content in block mode", async
);
});
type ChatLikePayload = {
messages?: Array<{ role?: string; content?: unknown }>;
input?: unknown;
choices?: Array<{ message?: { role?: string; content?: unknown } }>;
};
test("pii masker guardrail redacts request and response payloads", async () => {
// Request PII rewrite depends only on PII_REDACTION_ENABLED (not injection mode).
await withEnv(
{
INPUT_SANITIZER_MODE: "redact",
INPUT_SANITIZER_MODE: "block",
PII_REDACTION_ENABLED: "true",
PII_RESPONSE_SANITIZATION: "true",
PII_RESPONSE_SANITIZATION_MODE: "redact",
},
async () => {
const guardrail = new PIIMaskerGuardrail();
const preCall = await guardrail.preCall({
messages: [{ role: "user", content: "Email me at dev@example.com" }],
});
assert.ok(preCall?.modifiedPayload);
const preBody = preCall?.modifiedPayload as ChatLikePayload;
assert.match(String(preBody.messages?.[0]?.content), /\[EMAIL_REDACTED\]/);
// Responses API can send plain string items in input[]
const stringInput = await guardrail.preCall({
input: ["Contact us at support@example.com for help"],
});
assert.ok(stringInput?.modifiedPayload);
const stringBody = stringInput?.modifiedPayload as ChatLikePayload;
assert.match(
String((preCall?.modifiedPayload as Record<string, unknown>).messages?.[0]?.content),
String(Array.isArray(stringBody.input) ? stringBody.input[0] : undefined),
/\[EMAIL_REDACTED\]/
);
// Top-level string input
const topLevelInput = await guardrail.preCall({
input: "Reach alice@example.com",
});
assert.ok(topLevelInput?.modifiedPayload);
const topBody = topLevelInput?.modifiedPayload as ChatLikePayload;
assert.match(String(topBody.input), /\[EMAIL_REDACTED\]/);
const postCall = await guardrail.postCall({
choices: [
{
@@ -150,9 +173,8 @@ test("pii masker guardrail redacts request and response payloads", async () => {
],
});
assert.ok(postCall?.modifiedResponse, "PII in response should trigger redaction");
const redactedContent = String(
(postCall?.modifiedResponse as Record<string, unknown>).choices?.[0]?.message?.content
);
const postBody = postCall?.modifiedResponse as ChatLikePayload;
const redactedContent = String(postBody.choices?.[0]?.message?.content);
assert.ok(
redactedContent.includes("[EMAIL_REDACTED]") || redactedContent.includes("[PHONE_REDACTED]"),
"email or phone should be redacted in response"
@@ -161,6 +183,24 @@ test("pii masker guardrail redacts request and response payloads", async () => {
);
});
test("pii masker does not rewrite request PII when redaction flag is off", async () => {
await withEnv(
{
INPUT_SANITIZER_MODE: "redact",
PII_REDACTION_ENABLED: "false",
PII_RESPONSE_SANITIZATION: "false",
},
async () => {
const guardrail = new PIIMaskerGuardrail();
const preCall = await guardrail.preCall({
messages: [{ role: "user", content: "Email me at dev@example.com" }],
});
assert.equal(preCall?.modifiedPayload, undefined);
}
);
});
test("guardrail registry fails open when a guardrail throws", async () => {
class ExplodingGuardrail extends BaseGuardrail {
constructor() {

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeRequest } from "../../src/shared/utils/inputSanitizer.ts";
async function withEnv(overrides: Record<string, string | undefined>, fn: () => Promise<void> | void) {
const originals: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(overrides)) {
originals[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
await fn();
} finally {
for (const [key, value] of Object.entries(originals)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
const silentLogger = {
info() {},
warn() {},
error() {},
} as Pick<Console, "info" | "warn" | "error">;
type ChatBody = {
messages?: Array<{ role?: string; content?: unknown }>;
input?: unknown;
};
test("sanitizeRequest redacts PII when enabled even if injection mode is block", async () => {
await withEnv(
{
INPUT_SANITIZER_ENABLED: "true",
INPUT_SANITIZER_MODE: "block",
PII_REDACTION_ENABLED: "true",
},
() => {
const result = sanitizeRequest(
{ messages: [{ role: "user", content: "Email dev@example.com" }] },
silentLogger
);
assert.equal(result.modified, true);
assert.ok(result.sanitizedBody);
const body = result.sanitizedBody as ChatBody;
assert.match(String(body.messages?.[0]?.content), /\[EMAIL_REDACTED\]/);
}
);
});
test("sanitizeRequest redacts Responses API string input items", async () => {
await withEnv(
{
INPUT_SANITIZER_ENABLED: "true",
INPUT_SANITIZER_MODE: "warn",
PII_REDACTION_ENABLED: "true",
},
() => {
const result = sanitizeRequest(
{ input: ["Please write to support@example.com"] },
silentLogger
);
assert.equal(result.modified, true);
const body = result.sanitizedBody as ChatBody;
assert.match(String(Array.isArray(body.input) ? body.input[0] : undefined), /\[EMAIL_REDACTED\]/);
}
);
});
test("sanitizeRequest does not rewrite PII when PII_REDACTION_ENABLED is false", async () => {
await withEnv(
{
INPUT_SANITIZER_ENABLED: "true",
INPUT_SANITIZER_MODE: "redact",
PII_REDACTION_ENABLED: "false",
},
() => {
const result = sanitizeRequest(
{ messages: [{ role: "user", content: "Email dev@example.com" }] },
silentLogger
);
assert.equal(result.modified, false);
assert.equal(result.sanitizedBody, null);
}
);
});