Bound attachment metadata percent decoding

TerminallyLazy committed Aug 10, 2026 at 21:19 UTC 37093f588806e37d1dd98baf93400afa8a7a1ad6
2 files changed +33 -1
plugins/_a0_connector/api/ws_connector.py
+9 -1
@@ -74,6 +74,7 @@ WS_FEATURES = [
74 _SNAPSHOT_REPLAY_PAGE_SIZE = 50
75 _TAIL_HISTORY_PAGE_SIZE = 100
76 _LIVE_STREAM_PAGE_SIZE = 100
77 +_ATTACHMENT_METADATA_DECODE_LIMIT = 3
78
79
80 def _attachment_log_metadata(attachments: list[str]) -> dict[str, list[str]]:
@@ -87,7 +88,14 @@ def _attachment_log_metadata(attachments: list[str]) -> dict[str, list[str]]:
88 except ValueError:
89 continue
90 path = parsed.path if parsed.scheme else normalized.split("?", 1)[0].split("#", 1)[0]
90 - path = unquote(path).replace("\\", "/")
91 + for _ in range(_ATTACHMENT_METADATA_DECODE_LIMIT):
92 + decoded_path = unquote(path)
93 + if decoded_path == path:
94 + break
95 + path = decoded_path
96 + else:
97 + continue
98 + path = path.replace("\\", "/")
99 path = path.split("?", 1)[0].split("#", 1)[0]
100 if path.endswith("/"):
101 continue
tests/test_a0_connector_attachment_metadata.py
+24
@@ -52,6 +52,30 @@ def test_attachment_log_metadata_strips_encoded_query_and_fragment_suffixes() ->
52 ) == {"attachments": ["report", "image"]}
53
54
55 +def test_attachment_log_metadata_decodes_double_encoded_separators() -> None:
56 + assert _attachment_log_metadata(
57 + [
58 + "https://host/%252Fhome%252Fuser%252Fsecret.png",
59 + "https://host/C:%255CUsers%255CAlice%255Csecret.png",
60 + ]
61 + ) == {"attachments": ["secret.png", "secret.png"]}
62 +
63 +
64 +def test_attachment_log_metadata_strips_double_encoded_delimiter_suffixes() -> None:
65 + assert _attachment_log_metadata(
66 + [
67 + "https://host/report%253Ftoken%253Dredacted.png",
68 + "https://host/image%2523private-fragment.png",
69 + ]
70 + ) == {"attachments": ["report", "image"]}
71 +
72 +
73 +def test_attachment_log_metadata_omits_paths_exceeding_decode_limit() -> None:
74 + assert _attachment_log_metadata(
75 + ["https://host/%25252Fhome%25252Fuser%25252Fsecret.png"]
76 + ) == {}
77 +
78 +
79 class RecordingLog:
80 def __init__(self) -> None:
81 self.calls: list[dict[str, object]] = []