Skip to content

[Detail Bug] SSE error diagnostics leak downstream headers and bodies to clients #33

@detail-app

Description

@detail-app

Summary

  • Context: The ChatController handles streaming chat responses and returns error events when the LLM provider or downstream services fail.
  • Bug: The ChatController exposes sensitive downstream response details (headers and bodies) to the end-user by including them in the SSE error event's diagnostic payload.
  • Actual vs. expected: Downstream headers and response bodies from exceptions like WebClientResponseException and OpenAIServiceException are sent directly to the client; these should be redacted or omitted to prevent information leakage.
  • Impact: Potential exposure of sensitive internal metadata, downstream service details, or even authentication tokens to end-users.

Code with bug

// src/main/java/com/williamcallahan/javachat/web/ChatController.java

.onErrorResume(error -> {
    String errorDetail = buildUserFacingErrorMessage(error);
    String diagnostics =
            error instanceof Exception exception ? describeException(exception) : error.toString(); // <-- BUG 🔴 [Exposes full exception details including headers and bodies]
    PIPELINE_LOG.error(
            "[{}] STREAMING ERROR (sessionId={}, exceptionType={})",
            requestToken,
            sessionId,
            error.getClass().getSimpleName(),
            error);
    boolean retryable = openAIStreamingService.isRecoverableStreamingFailure(error);
    return sseSupport.streamErrorEvent(errorDetail, diagnostics, retryable);
});

Evidence

The describeException method in ExceptionResponseBuilder (called by ChatController via BaseController) explicitly appends response bodies and headers to the error description:

// src/main/java/com/williamcallahan/javachat/web/ExceptionResponseBuilder.java

private void appendWebClientDetails(StringBuilder details, WebClientResponseException exception) {
    details.append(" [httpStatus=").append(exception.getStatusCode().value());
    // ...
    String responseBody = exception.getResponseBodyAsString();
    if (!responseBody.isBlank()) {
        details.append(", body=").append(responseBody); // <-- BUG 🔴 [Leaks downstream response body]
    }
    HttpHeaders headers = exception.getHeaders();
    if (!headers.isEmpty()) {
        details.append(", headers=").append(headers); // <-- BUG 🔴 [Leaks all response headers]
    }
    details.append("]");
}

This diagnostic string is then passed to sseSupport.streamErrorEvent, which wraps it in a JSON payload and sends it over the SSE connection to the browser:

// src/main/java/com/williamcallahan/javachat/web/SseSupport.java

public Flux<ServerSentEvent<String>> streamErrorEvent(
        String userFacingMessage, String diagnosticDetails, boolean retryable) {
    // ...
    return sseError(SseEventPayload.builder(userFacingMessage)
            .details(diagnosticDetails) // <-- Diagnostic details (including leaked headers) are sent to the client
            .code(statusCode)
            .retryable(retryable)
            .stage(STATUS_STAGE_STREAM)
            .build());
}

Exploit scenario

  1. An attacker initiates a chat session that triggers a downstream error (e.g., by sending a specially crafted prompt that causes a 400 error from a downstream service, or simply waiting for a transient service failure).
  2. The downstream service returns an error with sensitive headers (e.g., internal tokens, proxy IDs, or server version info) or a response body containing internal system details.
  3. The Java backend catches the exception and, via ChatController, sends the full "diagnostics" string (including those headers and body) to the attacker's browser in an SSE error event.
  4. The attacker inspects the SSE stream (e.g., in the Network tab of browser dev tools) and retrieves the sensitive information.

Why has this bug gone undetected?

This bug often goes undetected because it only manifests during error conditions. Developers usually focus on the "user-facing message" and may not realize that the "technical details" or "diagnostics" payload being sent to the client contains unsafe information. In many environments, HttpHeaders.toString() might seem innocuous, but it does not reliably redact all sensitive or internal-only headers, especially when dealing with custom headers or different exception types like OpenAIServiceException which uses a standard Map.

Recommended fix

The describeException method should be modified to avoid including headers and response bodies in the user-facing diagnostic string. If such information is needed for debugging, it should be logged on the server side only.

// In src/main/java/com/williamcallahan/javachat/web/ExceptionResponseBuilder.java

private void appendWebClientDetails(StringBuilder details, WebClientResponseException exception) {
    details.append(" [httpStatus=").append(exception.getStatusCode().value());
    // Only include safe, high-level info for the client
    String statusText = exception.getStatusText();
    if (!statusText.isBlank()) {
        details.append(" ").append(statusText);
    }
    // DO NOT append body or headers here // <-- FIX 🟢
    details.append("]");
}

Related bugs

The same ExceptionResponseBuilder is used by BaseController, affecting other controllers that might expose technical diagnostics to the UI, such as IngestionController if it were to use similar error handling for client-initiated tasks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdetail

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions