From 4fd1f0f15c17cd57c8bbe3681a51113385c993c4 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:50:43 +0200 Subject: [PATCH] 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 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: rafaumeu --- .env.example | 5 +- SECURITY.md | 1 + docs/reference/ENVIRONMENT.md | 2 + docs/reference/FEATURE_FLAGS.md | 2 + docs/security/GUARDRAILS.md | 4 +- src/lib/guardrails/promptInjection.ts | 22 +-- src/middleware/promptInjectionGuard.ts | 15 +- src/shared/utils/envBoolean.ts | 21 +++ src/shared/utils/injectionSeverity.ts | 56 ++++++ src/shared/utils/inputSanitizer.ts | 13 +- tests/unit/sanitizer-residual-policy.test.ts | 179 +++++++++++++++++++ 11 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 src/shared/utils/envBoolean.ts create mode 100644 src/shared/utils/injectionSeverity.ts create mode 100644 tests/unit/sanitizer-residual-policy.test.ts diff --git a/.env.example b/.env.example index ebb1f7073c..3131df30cf 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/SECURITY.md b/SECURITY.md index a30594fce1..3499ef2ac2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 376a55eaab..2a83b341c0 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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`. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index f6fad8e32b..610bdb307e 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -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`. | diff --git a/docs/security/GUARDRAILS.md b/docs/security/GUARDRAILS.md index 8491caa211..9653207129 100644 --- a/docs/security/GUARDRAILS.md +++ b/docs/security/GUARDRAILS.md @@ -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. | diff --git a/src/lib/guardrails/promptInjection.ts b/src/lib/guardrails/promptInjection.ts index 6530e21554..eed2d21709 100644 --- a/src/lib/guardrails/promptInjection.ts +++ b/src/lib/guardrails/promptInjection.ts @@ -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 (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( diff --git a/src/middleware/promptInjectionGuard.ts b/src/middleware/promptInjectionGuard.ts index 8b144f493e..d257013278 100644 --- a/src/middleware/promptInjectionGuard.ts +++ b/src/middleware/promptInjectionGuard.ts @@ -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) { diff --git a/src/shared/utils/envBoolean.ts b/src/shared/utils/envBoolean.ts new file mode 100644 index 0000000000..aa9a1f701c --- /dev/null +++ b/src/shared/utils/envBoolean.ts @@ -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; +} diff --git a/src/shared/utils/injectionSeverity.ts b/src/shared/utils/injectionSeverity.ts new file mode 100644 index 0000000000..ae1e4867b6 --- /dev/null +++ b/src/shared/utils/injectionSeverity.ts @@ -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 = { + 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"; +} diff --git a/src/shared/utils/inputSanitizer.ts b/src/shared/utils/inputSanitizer.ts index 581ec0c99b..51448c0f68 100644 --- a/src/shared/utils/inputSanitizer.ts +++ b/src/shared/utils/inputSanitizer.ts @@ -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; } diff --git a/tests/unit/sanitizer-residual-policy.test.ts b/tests/unit/sanitizer-residual-policy.test.ts new file mode 100644 index 0000000000..4032dc123d --- /dev/null +++ b/tests/unit/sanitizer-residual-policy.test.ts @@ -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, + fn: () => Promise | void +) { + const originals: Record = {}; + 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; + +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\]/); + } + ); +});