Fix attachment replay metadata sanitization

TerminallyLazy committed Aug 10, 2026 at 21:06 UTC e9d527406403e119d34a0ed20f902f3c0eb3607c
2 files changed +61 -2
plugins/_a0_connector/api/ws_connector.py
+6 -2
@@ -3,7 +3,7 @@ from __future__ import annotations
3
4 import asyncio
5 from typing import TYPE_CHECKING, Any, ClassVar
6 -from urllib.parse import urlsplit
6 +from urllib.parse import unquote, urlsplit
7
8 from helpers.print_style import PrintStyle
9 from helpers.ws import WsHandler
@@ -82,8 +82,12 @@ def _attachment_log_metadata(attachments: list[str]) -> dict[str, list[str]]:
82 normalized = str(attachment or "").strip().replace("\\", "/")
83 if not normalized:
84 continue
85 - parsed = urlsplit(normalized)
85 + try:
86 + parsed = urlsplit(normalized)
87 + except ValueError:
88 + continue
89 path = parsed.path if parsed.scheme else normalized.split("?", 1)[0].split("#", 1)[0]
90 + path = unquote(path).replace("\\", "/")
91 if path.endswith("/"):
92 continue
93 name = path.rstrip("/").rsplit("/", 1)[-1]
tests/test_a0_connector_attachment_metadata.py
+55
@@ -34,6 +34,15 @@ def test_attachment_log_metadata_omits_empty_metadata() -> None:
34 assert _attachment_log_metadata(["", "/"]) == {}
35
36
37 +def test_attachment_log_metadata_decodes_encoded_separators() -> None:
38 + assert _attachment_log_metadata(
39 + [
40 + "https://host/%2Fhome%2Falice%2Fsecret.png",
41 + "https://host/C:%5CUsers%5CAlice%5Csecret.png",
42 + ]
43 + ) == {"attachments": ["secret.png", "secret.png"]}
44 +
45 +
46 class RecordingLog:
47 def __init__(self) -> None:
48 self.calls: list[dict[str, object]] = []
@@ -139,6 +148,52 @@ async def test_websocket_text_only_message_keeps_empty_kvps(
148 assert log.calls[0]["kvps"] == {}
149
150
151 +@pytest.mark.asyncio
152 +async def test_websocket_malformed_attachment_url_keeps_message_delivery(
153 + monkeypatch: pytest.MonkeyPatch,
154 +) -> None:
155 + log = RecordingLog()
156 + context = SimpleNamespace(log=log)
157 + handler = WsConnector(None, None)
158 + monkeypatch.setattr(
159 + handler,
160 + "_resolve_context",
161 + AsyncMock(return_value=(context, "ctx-1")),
162 + )
163 + monkeypatch.setattr(
164 + ws_module,
165 + "subscribed_contexts_for_sid",
166 + lambda sid: {"ctx-1"} if sid == "sid-cli" else set(),
167 + )
168 +
169 + scheduled: list[bool] = []
170 +
171 + def close_scheduled(coroutine: object) -> SimpleNamespace:
172 + getattr(coroutine, "close")()
173 + scheduled.append(True)
174 + return SimpleNamespace()
175 +
176 + monkeypatch.setattr(asyncio, "create_task", close_scheduled)
177 +
178 + result = await handler._handle_send_message(
179 + {
180 + "context_id": "ctx-1",
181 + "message": "Review this",
182 + "attachments": ["http://["],
183 + "client_message_id": "client-malformed",
184 + },
185 + "sid-cli",
186 + )
187 +
188 + assert result == {
189 + "context_id": "ctx-1",
190 + "status": "accepted",
191 + "client_message_id": "client-malformed",
192 + }
193 + assert scheduled == [True]
194 + assert log.calls[0]["kvps"] == {}
195 +
196 +
197 @pytest.mark.asyncio
198 @pytest.mark.parametrize(
199 ("payload", "code"),