Repository navigation
fix(http): sanitize HTTP error responses to avoid leaking internals - #10151
Conversation
Multiple paths in the HTTP service formatted dynamic error content
(anyhow chains, JoinError debug, backend-supplied messages from SSE
events) directly into the client response. These can expose internal
file paths, library versions, panic stack info, and raw internal error
JSON.
Affected paths:
- ErrorMessage::from_anyhow fallback ({err:#})
- ResourceExhausted branch (raw err.to_string)
- Three JoinError sites in completions/chat_completions/responses
handlers ({e:?})
- ErrorMessage::from_http_error for 5xx / out-of-range codes
- completions/chat/responses fold and parse error handlers
- MJPEG response builder
- check_for_backend_error for non-4xx events (panic text, raw
JSON, comment payloads, or backend-asserted 1xx/2xx/3xx codes)
- Streaming SSE error frame in monitor_for_disconnects, which
emitted the raw backend error across every streaming endpoint
(OpenAI chat/completions/videos and Anthropic messages)
- Anthropic 500 handlers: await-task JoinError, generate failure,
and fold-stream failure
- Cancellation (HTTP 499) handlers in from_anyhow and the Anthropic
generate path, which echoed raw backend cancellation text
(context IDs, "stopped or killed")
- Realtime WebSocket server_error frames: the outbound engine-error
frame and the engine-lookup failure handler echoed err.to_string()
Add ErrorMessage::internal_server_error_with_details(public_msg,
details) which logs the detail server-side and returns only the
sanitized public_msg to the client. Route the affected paths through
it, or trim the dynamic content from the client message where a
structured tracing::error! already captures it. The ResourceExhausted
branch now returns a static "Service temporarily overloaded" +
admission-control hint. from_http_error only forwards backend messages
in the documented 4xx range; everything else gets "Internal server
error". check_for_backend_error mirrors that contract: only
is_client_error() statuses are forwarded unchanged, everything else is
sanitized. The streaming SSE frame and the Anthropic 500 handlers now
log the full detail server-side and return a static "Internal server
error"; client-facing 4xx messages are intentionally preserved. The
499 cancellation paths now return a static "Request cancelled" and log
the detail server-side. The realtime WebSocket server_error frames now
return a static "Internal server error" and log the detail
server-side; the realtime model-not-found / model-unavailable frames
(which only echo the client-supplied model name) are preserved.
Update the unit tests that previously codified the leaky behavior to
assert the sanitized client messages and that the underlying detail
is absent (test_other_error_response_from_anyhow,
test_resource_exhausted_error_response_from_anyhow,
test_error_response_from_anyhow_out_of_range, the three
test_check_for_backend_error_* 500-path tests, the two
monitor_for_disconnects fault-contract tests, and
test_cancelled_error_response_from_anyhow). Add
test_check_for_backend_error_with_non_client_error_code and
test_mid_stream_error_does_not_leak_internal_details to lock in
sanitization for backend-asserted non-4xx codes and for streaming
path/panic/exception fragments.
Ref: DYN-2636
Signed-off-by: Pei Li <peili@nvidia.com>
3e2ad06 to
5887e83
Compare
WalkthroughThis PR sanitizes error handling across HTTP service handlers to prevent internal error details from leaking to clients. A new OpenAI helper logs full backend errors server-side while returning static public messages. All service handlers (OpenAI, Anthropic, Realtime, Disconnect) are updated to apply consistent sanitization patterns at spawn failures, generation errors, stream folding, and mid-stream backend events. Tests verify no sensitive details appear in client responses. ChangesError Sanitization Across HTTP Service Handlers
🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
lib/llm/src/http/service/openai.rs (2)
348-370:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon't forward backend 499 bodies unchanged.
from_anyhow()now sanitizes cancelled requests, but this helper still echoesHttpError { code: 499, message }verbatim. If an adapter/backend encodes cancellation that way, context IDs or backend cancellation text will leak again.💡 Suggested fix
pub fn from_http_error(err: HttpError) -> ErrorResponse { + if err.code == 499 { + let code = StatusCode::from_u16(499).unwrap(); + tracing::debug!("Request cancelled before response: {}", err.message); + return ( + code, + Json(ErrorMessage { + message: "Request cancelled".to_string(), + error_type: map_error_code_to_error_type(code), + code: code.as_u16(), + }), + ); + } + // Backend-supplied messages are only forwarded for the documented 4xx // range; for 5xx or codes outside the HTTP space the message may // contain internal paths/details and is kept server-side only. if err.code < 400 || err.code >= 500 {Please mirror the same carve-out in
check_for_backend_error()and add a regression test for a backendHttpError/error event with code499.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/llm/src/http/service/openai.rs` around lines 348 - 370, The helper from_http_error currently forwards HttpError with code 499 verbatim; change it to treat 499 like other non-forwarded codes by not echoing backend message (i.e., return ErrorMessage::internal_server_error_with_details or otherwise sanitize the message) so cancellation details don’t leak; apply the same carve-out in check_for_backend_error() so it also treats 499 as non-forwarded/sanitized; add a regression test that constructs an HttpError (or backend error event) with code 499 and asserts the response does not include the original backend message or context IDs.
1182-1224:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep scanning past leading annotations before declaring the stream clean.
chat_completions()andresponses()prepend annotation frames before calling this helper. When the first item is an annotation likerequest_id, this function reconstructs the stream immediately and never sees an immediate backendevent:errorright after it. On non-streaming requests that turns intended backend 4xxs into the later generic fold/parse 500 path.💡 Suggested fix
- // Peek at the first event - if let Some(first_event) = stream.next().await { - // Check if it's an error event - if let Some((error_msg, status_code)) = extract_backend_error_if_present(&first_event) { + let mut buffered = Vec::new(); + while let Some(event) = stream.next().await { + if let Some((error_msg, status_code)) = extract_backend_error_if_present(&event) { // existing error handling... } - // Not an error - reconstruct stream with first event - let reconstructed_stream = futures::stream::iter(vec![first_event]).chain(stream); - Ok(reconstructed_stream) - } else { - // Empty stream - this shouldn't happen but handle gracefully - Ok(futures::stream::iter(vec![]).chain(stream)) + let is_annotation = event.data.is_none() + && event.comment.is_none() + && event.event.as_deref() != Some("error"); + + buffered.push(event); + if !is_annotation { + break; + } } + + Ok(futures::stream::iter(buffered).chain(stream))A regression test with
ANNOTATION_REQUEST_IDfollowed by a backend 4xx error frame would lock this down.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/llm/src/http/service/openai.rs` around lines 1182 - 1224, check_for_backend_error currently only inspects the very first stream item and will miss an immediate backend error if the stream begins with annotation frames (e.g., ANNOTATION_REQUEST_ID); change it to consume and buffer any leading annotation frames (using whatever predicate identifies annotations in Annotated items), then inspect the first non-annotation event with extract_backend_error_if_present; if it’s an error return the appropriate ErrorResponse, otherwise reconstruct the stream by chaining the buffered annotation frames back in front of the remaining stream and return that reconstructed stream (update logic around extract_backend_error_if_present, the reconstructed_stream creation, and preserve behavior for empty streams).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@lib/llm/src/http/service/openai.rs`:
- Around line 348-370: The helper from_http_error currently forwards HttpError
with code 499 verbatim; change it to treat 499 like other non-forwarded codes by
not echoing backend message (i.e., return
ErrorMessage::internal_server_error_with_details or otherwise sanitize the
message) so cancellation details don’t leak; apply the same carve-out in
check_for_backend_error() so it also treats 499 as non-forwarded/sanitized; add
a regression test that constructs an HttpError (or backend error event) with
code 499 and asserts the response does not include the original backend message
or context IDs.
- Around line 1182-1224: check_for_backend_error currently only inspects the
very first stream item and will miss an immediate backend error if the stream
begins with annotation frames (e.g., ANNOTATION_REQUEST_ID); change it to
consume and buffer any leading annotation frames (using whatever predicate
identifies annotations in Annotated items), then inspect the first
non-annotation event with extract_backend_error_if_present; if it’s an error
return the appropriate ErrorResponse, otherwise reconstruct the stream by
chaining the buffered annotation frames back in front of the remaining stream
and return that reconstructed stream (update logic around
extract_backend_error_if_present, the reconstructed_stream creation, and
preserve behavior for empty streams).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 625a2610-4766-4c18-b187-57c35b4aedf5
📒 Files selected for processing (4)
lib/llm/src/http/service/anthropic.rslib/llm/src/http/service/disconnect.rslib/llm/src/http/service/openai.rslib/llm/src/http/service/realtime.rs
The HTTP error sanitization on this branch replaces raw backend error text in client responses with a static "Internal server error". Update test_chat_completion_http_error, which previously asserted the raw HttpError / ValueError text was forwarded to the client, to assert the sanitized message instead and that the underlying backend detail is absent. The status-code expectations are unchanged. Refs: DYN-2636 Signed-off-by: Pei Li <peili@nvidia.com>
…etails__DYN-2636 Fix failing CI
nnshah1
left a comment
There was a problem hiding this comment.
generally looks good - I think we can potentially refactor to simplify and also streamline comments - lets review the dynamo ops comments as well.
Address review feedback by routing every canonical sanitized error
response through one type instead of duplicating literals, and close
two leak paths flagged by coderabbitai that the earlier patch missed.
http::service::error::SanitizedError now owns:
- Cancelled -> 499, "Request cancelled"
- Overloaded -> 503, "Service temporarily overloaded. <hint>"
- Internal -> 500, "Internal server error"
- PreserveServerError(c) -> c, "Internal server error" (5xx pass-through)
Each variant exposes status(), Display, anthropic_type(),
openai_type_slug(), and log_as_error(); helpers
ErrorMessage::sanitized_with_details (openai.rs) and
anthropic_sanitized_error_with_details (anthropic.rs) build the
response from a variant and log the supplied details server-side.
Migrated all canonical sanitized sites across openai.rs, anthropic.rs,
disconnect.rs, and realtime.rs to use the enum.
Additional fixes:
- from_http_error previously forwarded HttpError { code: 499,
message: ... } verbatim because 499 sits inside the 4xx range.
Added a 499 carve-out that sanitizes via SanitizedError::Cancelled.
- check_for_backend_error inspected only the first stream item. The
request_id annotation prepended by chat_completions/responses
shadowed any immediate backend error event, dropping intended 4xx
responses to the generic fold/parse 500 path. Now buffers leading
annotation frames (no data/error, event tag != "error") via the
new is_annotation_frame helper and inspects the first non-
annotation event instead. Buffered frames are replayed in their
original order when no error is found.
Tests:
- test_from_http_error_sanitizes_499_message
- test_check_for_backend_error_skips_leading_annotation_frames
- test_check_for_backend_error_replays_leading_annotation_frames
Cancellation paths keep tracing::debug! (Cancelled.log_as_error() ==
false) to avoid client-disconnect noise dominating error logs.
Per-site sanitization comments removed throughout — the helper
docstrings carry the rationale.
DYN-2636
Signed-off-by: Pei Li <peili@nvidia.com>
|
Closing both coderabbit outside-diff items:
|
Reviewer feedback (vvagias): - realtime.rs handle_socket: engine.generate() failures put the raw backend error into the WebSocket close reason; startup failures could leak internals. Use SanitizedError::Internal.to_string() for the close reason; the original err is still tracing::error!-logged. - anthropic.rs check_for_backend_error rewrap: non-499 4xx errors were collapsed into HTTP 500. Preserve client-error statuses via anthropic_error (whose existing table maps 401/403/404/429 to spec-correct types). Self-review: - SanitizedError::PreserveServerError(503).anthropic_type() returned "api_error" instead of "overloaded_error" (same for 529). Both anthropic_type() and openai_type_slug() now consult the inner status of PreserveServerError so the spec-correct overload type surfaces. Added unit tests in error.rs. - check_for_backend_error buffered leading annotation frames in an unbounded Vec. A backend that emits only annotations could pin per-request memory. Cap at MAX_LEADING_ANNOTATIONS = 16; past the cap the loop falls through. - PreserveServerError(StatusCode) had no enforcement of its 5xx invariant. Added debug_assert in status() and documented the invariant; construct via SanitizedError::for_backend_status to enforce by construction. - The four-way status-classification ladder (499 / 4xx / 5xx / else) was duplicated in check_for_backend_error (openai.rs) and the anthropic.rs rewrap. Both now go through the new SanitizedError::for_backend_status helper — one source of truth. DYN-2636 Signed-off-by: Pei Li <peili@nvidia.com>
|
Self-review (effort=high, 7 finder agents over the http::service diff) surfaced four contained issues fixed in f0d3139:
Deferred (intentional, not regressions): the 115 tests pass, clippy + fmt clean. |
…etails__DYN-2636 # Conflicts: # lib/bindings/python/tests/test_http_server.py
…i-dynamo#10151) Signed-off-by: Pei Li <peili@nvidia.com>
…i-dynamo#10151) Signed-off-by: Pei Li <peili@nvidia.com> Signed-off-by: shenls <shenlinshan@kanzhun.com>
Overview
Sanitizes error responses across the HTTP service so internal details (file
paths, library versions, panic/stack text, raw backend exception messages,
context IDs) are no longer returned to clients. Addresses CWE-209 (Generation
of Error Message Containing Sensitive Information).
The pattern throughout: log the full detail server-side (
tracing), andreturn a static, sanitized message to the client. Client-facing 4xx
messages (validation, model-not-found, etc.) are intentionally preserved.
Surfaces closed
from_anyhowfallback,ResourceExhausted(503),from_http_error(5xx / out-of-range), fold/parse handlers, JoinError sites,MJPEG builder, and
check_for_backend_errorfor non-4xx events.monitor_for_disconnectsemitted the raw backend errorin its error frame, leaking across every streaming endpoint (OpenAI
chat/completions/videos and Anthropic messages). Single shared sink, fixed
once.
JoinError, generate failure, andfold-stream failure handlers.
from_anyhowand the Anthropic generate pathechoed raw backend cancellation text (context IDs, "stopped or killed"); now
return a static "Request cancelled".
engine-lookup failure handler echoed
err.to_string()intoserver_errorframes.
Tests
disconnect.rs) — theypreviously asserted the raw backend message reached the client (i.e. pinned
the leak). They now assert the sanitized static message and that the
backend detail is absent.
test_mid_stream_error_does_not_leak_internal_detailsdriving apath/panic/
ValueErrorpayload through the stream and asserting none of itreaches the client.
test_cancelled_error_response_from_anyhowto assert the staticmessage and absence of the context ID / cancellation text.
Verification
cargo clippy -p dynamo-llm --lib -- -D warnings— cleancargo test -p dynamo-llm --lib http::service— 99 passed, 0 failed🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes