Skip to content

bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204

Description

@moonming

Summary

The output guardrail (hook_point: "output") only fires on the non-streaming path. When a caller requests stream: true, the gateway forwards SSE chunks from upstream verbatim and never invokes state.guardrails.check_output(...) on the assembled assistant response. As a result, a forbidden literal that the model emits inside a streamed completion reaches the caller verbatim — bypassing the output guardrail entirely.

Where the gap is

crates/aisix-proxy/src/chat.rs has exactly one check_output call site, in the non-streaming branch around line 793 (post-#196). The streaming branch (build_sse_stream in the same file) wraps the upstream chunk stream and forwards rendered chunks but never accumulates the assistant text and never calls check_output. The same gap exists for /v1/responses streaming.

Severity

MEDIUM-HIGH for any deployment that has output guardrails configured AND serves streamed completions:

  • A customer enabling output guardrails reasonably expects them to apply uniformly across response shapes. There's no UI signal in the admin API saying "this guardrail is off when stream=true".
  • The gap is invisible: the guardrail is configured, fires correctly on non-streaming responses, and silently does nothing on streaming. Detection requires writing the kind of e2e regression that doesn't exist today.
  • For kind: "keyword" with literal/regex match, streaming makes the bypass trivial: any caller can just add stream: true to their request to skip the policy.

What "correct" looks like

Two reasonable shapes:

A. Buffer-then-check (simplest): accumulate the assistant content across chunks, run check_output once at end-of-stream, and if it blocks: emit an SSE event: error chunk and close (per docs §5 for abnormal termination). This loses TTFB advantages of streaming but matches the semantics of the non-streaming path.

B. Streaming guardrail (more invasive): invoke an output guardrail per-chunk on the partial accumulator. Block on first match, emit error chunk, close. Lower latency to first token, but content already streamed before the match still reaches the caller — for "keep forbidden content from caller" this can leak the prefix that triggered the match.

Most customer expectations align with A: "if I configured an output guardrail, I want forbidden content blocked, I'll trade TTFB for safety." Worth a config flag if shape B is also desired.

Repro shape (for the held-back e2e)

// Mock upstream emits SSE chunks; one of them carries FORBIDDEN_WORD
upstream = await startOpenAiUpstream({
  streamEvents: [
    `{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}`,
    `{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{"content":"sure here it is: ${FORBIDDEN_WORD}"},"finish_reason":null}]}`,
    `{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}]}`,
    "[DONE]",
  ],
});
// Configure output keyword guardrail on FORBIDDEN_WORD.
// Caller streams.
// Expected: stream throws (or emits SSE error event) BEFORE FORBIDDEN_WORD reaches the iterator.
// Today: the FORBIDDEN_WORD is delivered to the caller verbatim. Bug.

Surfaced by

Independent audit on PR #203 (#153 redaction fix). Audit MEDIUM-1: "streaming output path has no guardrail and no redaction test". This issue is out of scope for the redaction fix (which is about the error envelope shape) but is the same product surface — output guardrails — and affects the same security contract.

Companion

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions