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]] = []