diff --git a/tests/entrypoints/serve/utils/test_error_sanitization.py b/tests/entrypoints/serve/utils/test_error_sanitization.py new file mode 100644 index 0000000000..c871dffb40 --- /dev/null +++ b/tests/entrypoints/serve/utils/test_error_sanitization.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests that error messages in Anthropic and speech-to-text entrypoints +are sanitized to prevent memory address leakage. + +Verifies the fix for the incomplete CVE-2026-22778 remediation where +PIL repr addresses leaked via the Anthropic API router and the +speech-to-text WebSocket paths. +""" + +import pytest + +from vllm.entrypoints.serve.utils.api_utils import sanitize_message + + +class TestSanitizeMessageCoversLeakPatterns: + """Ensure sanitize_message strips addresses from realistic exceptions.""" + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "cannot identify image file <_io.BytesIO object at 0x7a95e299e750>", + "cannot identify image file <_io.BytesIO object>", + ), + ( + "cannot identify image file <_io.BytesIO object at 0x7f3c1a2b4d90>", + "cannot identify image file <_io.BytesIO object>", + ), + ( + "", + "", + ), + ( + "Error processing <_io.BytesIO object at 0xdeadbeef>: invalid header", + "Error processing <_io.BytesIO object>: invalid header", + ), + ], + ids=[ + "bytesio-standard", + "bytesio-different-addr", + "pil-image-repr", + "mid-string-repr", + ], + ) + def test_address_stripped(self, raw: str, expected: str): + assert sanitize_message(raw) == expected + + def test_safe_message_unchanged(self): + msg = "Invalid request: missing 'messages' field" + assert sanitize_message(msg) == msg + + def test_multiple_addresses_stripped(self): + raw = " and " + result = sanitize_message(raw) + assert "0x" not in result + + +class TestAffectedModulesUseSanitize: + """Verify that affected modules call sanitize_message (source-level).""" + + @pytest.mark.parametrize( + "module", + [ + "vllm.entrypoints.anthropic.api_router", + "vllm.entrypoints.anthropic.serving", + "vllm.entrypoints.speech_to_text.realtime.connection", + ], + ) + def test_module_calls_sanitize_message(self, module: str): + import importlib.util + from pathlib import Path + + spec = importlib.util.find_spec(module) + assert spec is not None and spec.origin is not None, ( + f"Cannot locate module {module}" + ) + source = Path(spec.origin).read_text() + assert "sanitize_message" in source, f"{module} does not call sanitize_message" + assert "import" in source and "sanitize_message" in source diff --git a/vllm/entrypoints/anthropic/api_router.py b/vllm/entrypoints/anthropic/api_router.py index 50a8dae9ec..16756a9028 100644 --- a/vllm/entrypoints/anthropic/api_router.py +++ b/vllm/entrypoints/anthropic/api_router.py @@ -19,6 +19,7 @@ from vllm.entrypoints.anthropic.serving import AnthropicServingMessages from vllm.entrypoints.openai.engine.protocol import ErrorResponse from vllm.entrypoints.serve.utils.api_utils import ( load_aware_call, + sanitize_message, validate_json_request, with_cancellation, ) @@ -75,7 +76,7 @@ async def create_messages(request: AnthropicMessagesRequest, raw_request: Reques content=AnthropicErrorResponse( error=AnthropicError( type="internal_error", - message=str(e), + message=sanitize_message(str(e)), ) ).model_dump(), ) @@ -121,7 +122,7 @@ async def count_tokens(request: AnthropicCountTokensRequest, raw_request: Reques content=AnthropicErrorResponse( error=AnthropicError( type="internal_error", - message=str(e), + message=sanitize_message(str(e)), ) ).model_dump(), ) diff --git a/vllm/entrypoints/anthropic/serving.py b/vllm/entrypoints/anthropic/serving.py index 806261b597..8f6cccdb0f 100644 --- a/vllm/entrypoints/anthropic/serving.py +++ b/vllm/entrypoints/anthropic/serving.py @@ -44,6 +44,7 @@ from vllm.entrypoints.openai.engine.protocol import ( StreamOptions, ) from vllm.entrypoints.openai.models.serving import OpenAIServingModels +from vllm.entrypoints.serve.utils.api_utils import sanitize_message from vllm.entrypoints.serve.utils.request_logger import RequestLogger if TYPE_CHECKING: @@ -846,7 +847,9 @@ class AnthropicServingMessages(OpenAIServingChat): logger.exception("Error in message stream converter.") error_response = AnthropicStreamEvent( type="error", - error=AnthropicError(type="internal_error", message=str(e)), + error=AnthropicError( + type="internal_error", message=sanitize_message(str(e)) + ), ) data = error_response.model_dump_json(exclude_unset=True) yield wrap_data_with_event(data, "error") diff --git a/vllm/entrypoints/speech_to_text/realtime/connection.py b/vllm/entrypoints/speech_to_text/realtime/connection.py index c7d1af9299..32f501f104 100644 --- a/vllm/entrypoints/speech_to_text/realtime/connection.py +++ b/vllm/entrypoints/speech_to_text/realtime/connection.py @@ -14,6 +14,7 @@ from starlette.websockets import WebSocketDisconnect from vllm import envs from vllm.entrypoints.openai.engine.protocol import ErrorResponse, UsageInfo +from vllm.entrypoints.serve.utils.api_utils import sanitize_message from vllm.exceptions import VLLMValidationError from vllm.logger import init_logger @@ -72,7 +73,7 @@ class RealtimeConnection: await self.send_error("Invalid JSON", "invalid_json") except Exception as e: logger.exception("Error handling event: %s", e) - await self.send_error(str(e), "processing_error") + await self.send_error(sanitize_message(str(e)), "processing_error") except WebSocketDisconnect: logger.debug("WebSocket disconnected: %s", self.connection_id) self._is_connected = False @@ -262,7 +263,7 @@ class RealtimeConnection: except Exception as e: logger.exception("Error in generation: %s", e) - await self.send_error(str(e), "processing_error") + await self.send_error(sanitize_message(str(e)), "processing_error") async def send( self, event: SessionCreated | TranscriptionDelta | TranscriptionDone