fix(guardrails): align INPUT_SANITIZER request masking gate (#8093) (#8124)

Scoped to guardrails/security: dropped the unrelated js-yaml/tar/shell-quote/
brace-expansion override bumps, and isolated sanitizer-residual-policy.test.ts
to a tmp DATA_DIR so it no longer touches the real storage.sqlite.

Co-authored-by: RaviTharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: rafaumeu <rafael.zendron22@gmail.com>
This commit is contained in:
Ravi Tharuma
2026-07-23 09:50:43 +02:00
committed by GitHub
parent f664af8f36
commit 4fd1f0f15c
11 changed files with 301 additions and 19 deletions

View File

@@ -352,11 +352,14 @@ ALLOW_API_KEY_REVEAL=false
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# Default ON when unset. Set to false/0/no/off to disable. Truthy: true/1/yes/on.
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII)
# INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# Legacy aliases for INPUT_SANITIZER_MODE / INPUT_SANITIZER_BLOCK_THRESHOLD (same effect).
# INJECTION_GUARD_MODE=warn
# INJECTION_GUARD_BLOCK_THRESHOLD=high
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.

View File

@@ -98,6 +98,7 @@ Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
```
### 🔒 PII Redaction

View File

@@ -222,6 +222,8 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `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` | 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. |
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | `high` | `src/shared/utils/injectionSeverity.ts` | Minimum severity that `MODE=block` rejects: `high` (default), `medium`, or `low`. Medium patterns are observe-only unless lowered. |
| `INJECTION_GUARD_BLOCK_THRESHOLD` | _(unset)_ | `src/shared/utils/injectionSeverity.ts` | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD` — same behavior. |
| `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`. |

View File

@@ -56,6 +56,8 @@ 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`. |
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | enum | `high` | Minimum severity blocked when mode is `block` (`high`/`medium`/`low`). Medium families are observe-only at default. |
| `INJECTION_GUARD_BLOCK_THRESHOLD` | enum | _(unset)_ | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD`. |
| `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`. |

View File

@@ -85,7 +85,7 @@ options:
| --------------- | ----------------------------------------------- | ------- | --------------------------------------- |
| Enabled | `INPUT_SANITIZER_ENABLED` | `true` | When `false`, guardrail short-circuits. |
| 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. |
| Block threshold | `blockThreshold` option / `INPUT_SANITIZER_BLOCK_THRESHOLD` (alias `INJECTION_GUARD_BLOCK_THRESHOLD`) | `high` | Minimum severity required to block. Medium is observe-only at default. |
**Mode precedence** (`getMode`): caller `options.mode`
`INJECTION_GUARD_MODE` **DB feature-flag override** (Dashboard → Settings →
@@ -226,6 +226,8 @@ Environment variables read by the built-in guardrails:
| `INPUT_SANITIZER_ENABLED` | `prompt-injection` | Set `false` to disable detection entirely. |
| `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). |
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | `prompt-injection` | Minimum severity that `MODE=block` rejects: `high` (default), `medium`, or `low`. |
| `INJECTION_GUARD_BLOCK_THRESHOLD` | `prompt-injection` | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD`. |
| `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. |

View File

@@ -5,6 +5,13 @@ import {
sanitizeRequest,
} from "@/shared/utils/inputSanitizer";
import { getFeatureFlagOverride } from "@/lib/db/featureFlags";
import { parseEnvBoolean } from "@/shared/utils/envBoolean";
import {
resolveBlockThreshold,
shouldBlockDetections,
SEVERITY_SCORES as SHARED_SEVERITY_SCORES,
type InjectionSeverity,
} from "@/shared/utils/injectionSeverity";
type Detection = {
match: string;
@@ -52,11 +59,7 @@ const DEFAULT_GUARD_PATTERNS: PatternLike[] = [
},
];
const SEVERITY_SCORES = {
high: 3,
low: 1,
medium: 2,
};
const SEVERITY_SCORES = SHARED_SEVERITY_SCORES;
function normalizePatternEntry(entry: PatternLike, index: number) {
if (entry instanceof RegExp) {
@@ -104,10 +107,7 @@ function detectWithPatterns(text: string, patterns: ReturnType<typeof normalizeP
}
function shouldBlock(detections: Detection[], threshold: "low" | "medium" | "high") {
const minimumSeverity = SEVERITY_SCORES[threshold] || SEVERITY_SCORES.high;
return detections.some(
(detection) => (SEVERITY_SCORES[detection.severity] || 0) >= minimumSeverity
);
return shouldBlockDetections(detections, threshold);
}
function getLogger(options: PromptInjectionGuardrailOptions, context: GuardrailContext) {
@@ -152,11 +152,11 @@ function getMode(options: PromptInjectionGuardrailOptions) {
}
function getThreshold(options: PromptInjectionGuardrailOptions) {
return (options.blockThreshold || "high") as "low" | "medium" | "high";
return resolveBlockThreshold(options.blockThreshold);
}
function isEnabled(options: PromptInjectionGuardrailOptions) {
return options.enabled ?? process.env.INPUT_SANITIZER_ENABLED !== "false";
return options.enabled ?? parseEnvBoolean(process.env.INPUT_SANITIZER_ENABLED, true);
}
export function evaluatePromptInjection(

View File

@@ -83,10 +83,19 @@ export function withInjectionGuard(handler: any, options: any = {}) {
);
}
// Attach sanitization result as header for downstream handlers
// Attach sanitization result as header for downstream handlers.
// Web Request headers may be immutable — never let this throw into the
// outer security-check path (issue #8095).
if (result.flagged) {
request.headers.set("X-Injection-Flagged", "true");
request.headers.set("X-Injection-Detections", String(result.detections.length));
try {
request.headers.set("X-Injection-Flagged", "true");
request.headers.set(
"X-Injection-Detections",
String(result.detections.length)
);
} catch {
// immutable headers: detection still applied; metadata is best-effort
}
}
}
} catch (error) {

View File

@@ -0,0 +1,21 @@
/**
* Shared env/flag boolean parsing for security guards.
*
* Truthy: true / 1 / yes / on (case-insensitive, trimmed)
* Falsy: false / 0 / no / off
* Unset/empty/unknown → fallback
*
* @module shared/utils/envBoolean
*/
export function parseEnvBoolean(
value: string | undefined | null,
fallback: boolean
): boolean {
if (value === undefined || value === null) return fallback;
const normalized = String(value).trim().toLowerCase();
if (normalized === "") return fallback;
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return fallback;
}

View File

@@ -0,0 +1,56 @@
/**
* Shared prompt-injection severity scoring / block threshold helpers.
* Kept separate from promptInjection.ts so inputSanitizer can reuse them
* without a circular import (promptInjection → sanitizeRequest).
*
* @module shared/utils/injectionSeverity
*/
export type InjectionSeverity = "low" | "medium" | "high";
export const SEVERITY_SCORES: Record<InjectionSeverity, number> = {
low: 1,
medium: 2,
high: 3,
};
export type DetectionLike = {
severity?: string;
};
/**
* Whether detections meet the configured block threshold.
* Default threshold is "high" — medium patterns are observe-only unless
* INPUT_SANITIZER_BLOCK_THRESHOLD is lowered.
*/
export function shouldBlockDetections(
detections: DetectionLike[],
threshold: InjectionSeverity = "high"
): boolean {
const minimumSeverity = SEVERITY_SCORES[threshold] || SEVERITY_SCORES.high;
return detections.some((detection) => {
const score =
SEVERITY_SCORES[(detection.severity as InjectionSeverity) || "high"] || 0;
return score >= minimumSeverity;
});
}
/**
* Resolve block threshold from options / env.
* Env: INPUT_SANITIZER_BLOCK_THRESHOLD or INJECTION_GUARD_BLOCK_THRESHOLD
* Allowed: low | medium | high (default high)
*/
export function resolveBlockThreshold(
explicit?: InjectionSeverity | string | null
): InjectionSeverity {
const raw =
(explicit && String(explicit)) ||
process.env.INPUT_SANITIZER_BLOCK_THRESHOLD ||
process.env.INJECTION_GUARD_BLOCK_THRESHOLD ||
"high";
const normalized = String(raw).trim().toLowerCase();
if (normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return "high";
}

View File

@@ -7,6 +7,9 @@
* @module inputSanitizer
*/
import { parseEnvBoolean } from "@/shared/utils/envBoolean";
import { resolveBlockThreshold, shouldBlockDetections } from "@/shared/utils/injectionSeverity";
// ─── Prompt Injection Patterns ───────────────────────────────────────
/** @type {Array<{name: string, pattern: RegExp, severity: string}>} */
@@ -111,9 +114,11 @@ const PII_PATTERNS = [
*/
function getConfig() {
return {
enabled: process.env.INPUT_SANITIZER_ENABLED !== "false",
// Default ON (opt-out). Truthy/falsy parsing accepts true/1/yes/on and false/0/no/off.
enabled: parseEnvBoolean(process.env.INPUT_SANITIZER_ENABLED, true),
mode: process.env.INPUT_SANITIZER_MODE || "warn", // "warn" | "block" | "redact"
piiRedaction: process.env.PII_REDACTION_ENABLED === "true",
piiRedaction: parseEnvBoolean(process.env.PII_REDACTION_ENABLED, false),
blockThreshold: resolveBlockThreshold(),
};
}
@@ -271,7 +276,9 @@ export function sanitizeRequest(body, logger = console) {
);
}
if (config.mode === "block" && highSeverity.length > 0) {
// Shared threshold policy with evaluatePromptInjection / createInjectionGuard.
// Default threshold is "high" (medium is observe-only unless lowered via env).
if (config.mode === "block" && shouldBlockDetections(injections, config.blockThreshold)) {
result.blocked = true;
return result;
}

View File

@@ -0,0 +1,179 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Isolate DB state so this suite never touches the real ~/.omniroute/storage.sqlite.
// DATA_DIR is resolved eagerly at module-load time in src/lib/db/core.ts, so it
// MUST be set before promptInjection.ts / piiMasker.ts (which transitively import
// @/lib/db/featureFlags -> @/lib/db/core) are imported. Use dynamic import() after
// setting the env var, matching tests/unit/pii-opt-in-default.test.ts.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-sanitizer-residual-"));
process.env.DATA_DIR = tmpDir;
const { parseEnvBoolean } = await import("../../src/shared/utils/envBoolean.ts");
const { resolveBlockThreshold, shouldBlockDetections } = await import(
"../../src/shared/utils/injectionSeverity.ts"
);
const { sanitizeRequest } = await import("../../src/shared/utils/inputSanitizer.ts");
const { evaluatePromptInjection } = await import("../../src/lib/guardrails/promptInjection.ts");
const { PIIMaskerGuardrail } = await import("../../src/lib/guardrails/piiMasker.ts");
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
test.after(() => {
resetDbInstance();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
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">;
test("parseEnvBoolean accepts common truthy/falsy forms and fallbacks", () => {
assert.equal(parseEnvBoolean(undefined, true), true);
assert.equal(parseEnvBoolean("", false), false);
assert.equal(parseEnvBoolean("true", false), true);
assert.equal(parseEnvBoolean("TRUE", false), true);
assert.equal(parseEnvBoolean("1", false), true);
assert.equal(parseEnvBoolean("yes", false), true);
assert.equal(parseEnvBoolean("on", false), true);
assert.equal(parseEnvBoolean("false", true), false);
assert.equal(parseEnvBoolean("FALSE", true), false);
assert.equal(parseEnvBoolean("0", true), false);
assert.equal(parseEnvBoolean("no", true), false);
assert.equal(parseEnvBoolean("off", true), false);
assert.equal(parseEnvBoolean("maybe", true), true);
assert.equal(parseEnvBoolean("maybe", false), false);
});
test("shouldBlockDetections honors threshold defaults and medium/high", () => {
const medium = [{ severity: "medium" }];
const high = [{ severity: "high" }];
assert.equal(shouldBlockDetections(medium, "high"), false);
assert.equal(shouldBlockDetections(high, "high"), true);
assert.equal(shouldBlockDetections(medium, "medium"), true);
assert.equal(shouldBlockDetections(medium, "low"), true);
assert.equal(resolveBlockThreshold(undefined), "high");
assert.equal(resolveBlockThreshold("medium"), "medium");
});
test("sanitizeRequest disables with false/0/no and stays on when unset", async () => {
await withEnv({ INPUT_SANITIZER_ENABLED: undefined }, () => {
const result = sanitizeRequest(
{ messages: [{ role: "user", content: "hello" }] },
silentLogger
);
// enabled path returns a full result object
assert.equal(result.blocked, false);
});
for (const off of ["false", "0", "no", "off", "FALSE"]) {
await withEnv({ INPUT_SANITIZER_ENABLED: off }, () => {
const result = sanitizeRequest(
{ messages: [{ role: "user", content: "hello" }] },
silentLogger
);
assert.equal(result.detections.length, 0);
assert.equal(result.blocked, false);
});
}
});
test("sanitizeRequest and evaluatePromptInjection share high-default threshold", async () => {
await withEnv(
{
INPUT_SANITIZER_ENABLED: "true",
INPUT_SANITIZER_MODE: "block",
INPUT_SANITIZER_BLOCK_THRESHOLD: "high",
},
() => {
// Medium-only detections should not block at default threshold.
// Use a content shape that is unlikely to also trip high patterns.
const body = {
messages: [{ role: "user", content: "Please act as a different assistant persona for this task." }],
};
const sanitized = sanitizeRequest(body, silentLogger);
const evaluated = evaluatePromptInjection(body, {}, { log: silentLogger });
// If medium patterns matched, neither path should block under high threshold.
if (sanitized.detections.some((d) => d.severity === "medium") &&
!sanitized.detections.some((d) => d.severity === "high")) {
assert.equal(sanitized.blocked, false);
}
if (evaluated.result.detections.some((d) => d.severity === "medium") &&
!evaluated.result.detections.some((d) => d.severity === "high")) {
assert.equal(evaluated.blocked, false);
}
}
);
});
test("sanitizeRequest redacts request PII independent of injection mode", 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 {
messages?: Array<{ content?: unknown }>;
};
assert.match(String(body.messages?.[0]?.content), /\[EMAIL_REDACTED\]/);
}
);
});
test("PIIMaskerGuardrail redacts Responses string input under MODE=block", async () => {
await withEnv(
{
INPUT_SANITIZER_MODE: "block",
PII_REDACTION_ENABLED: "true",
},
async () => {
const guardrail = new PIIMaskerGuardrail();
const preCall = await guardrail.preCall({
input: ["Contact support@example.com"],
});
assert.ok(preCall?.modifiedPayload);
const body = preCall?.modifiedPayload as { input?: unknown };
assert.match(
String(Array.isArray(body.input) ? body.input[0] : undefined),
/\[EMAIL_REDACTED\]/
);
const top = await guardrail.preCall({ input: "Reach alice@example.com" });
assert.ok(top?.modifiedPayload);
const topBody = top?.modifiedPayload as { input?: unknown };
assert.match(String(topBody.input), /\[EMAIL_REDACTED\]/);
}
);
});