| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Tests that error messages in Anthropic and speech-to-text entrypoints |
| 4 | are sanitized to prevent memory address leakage. |
| 5 | |
| 6 | Verifies the fix for the incomplete CVE-2026-22778 remediation where |
| 7 | PIL repr addresses leaked via the Anthropic API router and the |
| 8 | speech-to-text WebSocket paths. |
| 9 | """ |
| 10 | |
| 11 | import pytest |
| 12 | |
| 13 | from vllm.entrypoints.serve.exception_handling.utils import sanitize_message |
| 14 | |
| 15 | |
| 16 | def test_sanitize_message(): |
| 17 | assert ( |
| 18 | sanitize_message("<_io.BytesIO object at 0x7a95e299e750>") |
| 19 | == "<_io.BytesIO object>" |
| 20 | ) |
| 21 | assert ( |
| 22 | sanitize_message("<PIL.Image.Image image mode=RGB size=2x2 at 0x7D52EAF07BF0>") |
| 23 | == "<PIL.Image.Image image mode=RGB size=2x2>" |
| 24 | ) |
| 25 | |
| 26 | |
| 27 | class TestSanitizeMessageFilePaths: |
| 28 | """sanitize_message should also strip file paths and traceback |
| 29 | frames, not just memory addresses - see #31683.""" |
| 30 | |
| 31 | def test_strips_traceback_style_frame(self): |
| 32 | msg = ( |
| 33 | "1 validation error:\n" |
| 34 | " {'type': 'list_type', 'loc': ('body', 'messages')}\n" |
| 35 | '\n File "/usr/local/lib/python3.12/dist-packages/vllm/' |
| 36 | 'entrypoints/serve/utils/api_utils.py", line 40, ' |
| 37 | "in create_chat_completion\n" |
| 38 | " POST /v1/chat/completions" |
| 39 | ) |
| 40 | result = sanitize_message(msg) |
| 41 | assert "/usr/local/" not in result |
| 42 | assert "api_utils.py" not in result |
| 43 | assert "list_type" in result |
| 44 | |
| 45 | def test_strips_arbitrary_absolute_path(self): |
| 46 | result = sanitize_message("Error in /home/user/project/vllm/server.py") |
| 47 | assert "/home/user" not in result |
| 48 | |
| 49 | def test_strips_single_parent_container_path(self): |
| 50 | """Regression: /app/server.py and /workspace/server.py (common in |
| 51 | container deployments) were missed by the original {2,} quantifier.""" |
| 52 | assert "/app/" not in sanitize_message("Error in /app/server.py") |
| 53 | assert "/workspace/" not in sanitize_message("Error in /workspace/server.py") |
| 54 | |
| 55 | def test_preserves_api_endpoint_paths(self): |
| 56 | msg = "POST /v1/chat/completions failed" |
| 57 | assert "/v1/chat/completions" in sanitize_message(msg) |
| 58 | |
| 59 | def test_preserves_short_field_references(self): |
| 60 | msg = "Invalid value for field 'body.messages'" |
| 61 | assert sanitize_message(msg) == msg |
| 62 | |
| 63 | def test_strips_both_address_and_path(self): |
| 64 | msg = ( |
| 65 | "<Request at 0x7f123> failed at " |
| 66 | "/usr/local/lib/python3.12/dist-packages/vllm/server.py" |
| 67 | ) |
| 68 | result = sanitize_message(msg) |
| 69 | assert "0x" not in result |
| 70 | assert "/usr/local/" not in result |
| 71 | |
| 72 | |
| 73 | class TestSanitizeMessageCoversLeakPatterns: |
| 74 | """Ensure sanitize_message strips addresses from realistic exceptions.""" |
| 75 | |
| 76 | @pytest.mark.parametrize( |
| 77 | ("raw", "expected"), |
| 78 | [ |
| 79 | ( |
| 80 | "cannot identify image file <_io.BytesIO object at 0x7a95e299e750>", |
| 81 | "cannot identify image file <_io.BytesIO object>", |
| 82 | ), |
| 83 | ( |
| 84 | "cannot identify image file <_io.BytesIO object at 0x7f3c1a2b4d90>", |
| 85 | "cannot identify image file <_io.BytesIO object>", |
| 86 | ), |
| 87 | ( |
| 88 | "<PIL.PngImagePlugin.PngImageFile image mode=RGB " |
| 89 | "size=8x8 at 0x7f3c1a2b4d90>", |
| 90 | "<PIL.PngImagePlugin.PngImageFile image mode=RGB size=8x8>", |
| 91 | ), |
| 92 | ( |
| 93 | "Error processing <_io.BytesIO object at 0xdeadbeef>: invalid header", |
| 94 | "Error processing <_io.BytesIO object>: invalid header", |
| 95 | ), |
| 96 | ], |
| 97 | ids=[ |
| 98 | "bytesio-standard", |
| 99 | "bytesio-different-addr", |
| 100 | "pil-image-repr", |
| 101 | "mid-string-repr", |
| 102 | ], |
| 103 | ) |
| 104 | def test_address_stripped(self, raw: str, expected: str): |
| 105 | assert sanitize_message(raw) == expected |
| 106 | |
| 107 | def test_safe_message_unchanged(self): |
| 108 | msg = "Invalid request: missing 'messages' field" |
| 109 | assert sanitize_message(msg) == msg |
| 110 | |
| 111 | def test_multiple_addresses_stripped(self): |
| 112 | raw = "<obj at 0xaaa> and <obj at 0xbbb>" |
| 113 | result = sanitize_message(raw) |
| 114 | assert "0x" not in result |
| 115 | |
| 116 | |
| 117 | class TestAffectedModulesUseSanitize: |
| 118 | """Verify that affected modules call sanitize_message (source-level).""" |
| 119 | |
| 120 | @pytest.mark.parametrize( |
| 121 | "module", |
| 122 | [ |
| 123 | "vllm.entrypoints.anthropic.serving", |
| 124 | "vllm.entrypoints.speech_to_text.realtime.connection", |
| 125 | ], |
| 126 | ) |
| 127 | def test_module_calls_sanitize_message(self, module: str): |
| 128 | import importlib.util |
| 129 | from pathlib import Path |
| 130 | |
| 131 | spec = importlib.util.find_spec(module) |
| 132 | assert spec is not None and spec.origin is not None, ( |
| 133 | f"Cannot locate module {module}" |
| 134 | ) |
| 135 | source = Path(spec.origin).read_text() |
| 136 | assert "sanitize_message" in source, f"{module} does not call sanitize_message" |
| 137 | assert "import" in source and "sanitize_message" in source |