Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonming moonming commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
  "error": {
    "message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
    "type": "content_filter"
  }
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block { reason } => {
        tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason, "guardrail blocked response");
        return Err(with_model(ProxyError::ContentFiltered(
            "response blocked by content policy".into(),
        )));
    }

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

Copilot AI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

📒 Files selected for processing (5)
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/error.rs
  • crates/aisix-proxy/src/lib.rs
  • tests/e2e/src/cases/guardrail-keyword-e2e.test.ts
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
  • crates/aisix-proxy/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/error.rs

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s) Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
crates/aisix-proxy/src/error.rs Changes ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rs Redacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rs Strengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.ts Adds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes #153)

When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:

```json
{
  "error": {
    "message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
    "type": "content_filter"
  }
}
```

For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.

Redact at the wire boundary, keep rich detail in operator logs.

- `ProxyError::ContentFiltered`'s Display impl changed from
  `"content blocked by policy: {0}"` to `"{0}"` so constructors fully
  control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
  guardrail at L299, output guardrail at L651) now build a generic
  static message:
  - input: `"request blocked by content policy"`
  - output: `"response blocked by content policy"`
  and emit the verdict's rich `reason` (which contains the matched
  literal and rule type) via `tracing::warn!` for operator debugging.

- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
  unit test: replaced the `.contains("forbidden-token")` assertion
  (which pinned the leaky behavior) with `!message.contains(...)` plus
  an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
  to assert the matched literal `"secret-string"` does NOT appear in
  ANY field of the wire envelope (full-blob substring check), and that
  the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
  exercising the user journey through the OpenAI Node SDK against a
  live mock upstream that emits a forbidden literal in the assistant
  response. Asserts:
  - 422 with `error.type === "content_filter"`
  - `errorBlob.not.toContain(FORBIDDEN_WORD)`
  - upstream WAS hit (output guardrails fire post-dispatch)

- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean

Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
Copilot AI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonming force-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374 Compare May 10, 2026 06:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into main May 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
  body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
  a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
  warning, 401/403 verification step using the real proxy error envelope,
  Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
  path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
  with the real ProxyError mapping; add 413 RequestTooLarge row; note the
  admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
  the inaccurate OpenAI api_base normalization claim with a per-provider
  truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
  the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
  list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
  routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
  (x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
  (422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
  anthropic-upstream-e2e; document bare-host api_base for Anthropic

Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
  body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
  a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
  warning, 401/403 verification step using the real proxy error envelope,
  Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
  path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
  with the real ProxyError mapping; add 413 RequestTooLarge row; note the
  admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
  the inaccurate OpenAI api_base normalization claim with a per-provider
  truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
  the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
  list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
  routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
  (x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
  (422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
  anthropic-upstream-e2e; document bare-host api_base for Anthropic

Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: output guardrail error message echoes the matched forbidden literal back to caller

2 participants