Skip to content

Commit 900c955

Browse files
fix: sanitize endpoint path params
1 parent 311a998 commit 900c955

File tree

8 files changed

+252
-27
lines changed

8 files changed

+252
-27
lines changed

src/beeper_desktop_api/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._path import path_template as path_template
12
from ._sync import asyncify as asyncify
23
from ._proxy import LazyProxy as LazyProxy
34
from ._utils import (
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import (
5+
Any,
6+
Mapping,
7+
Callable,
8+
)
9+
from urllib.parse import quote
10+
11+
# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
12+
_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
13+
14+
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
15+
16+
17+
def _quote_path_segment_part(value: str) -> str:
18+
"""Percent-encode `value` for use in a URI path segment.
19+
20+
Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
21+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
22+
"""
23+
# quote() already treats unreserved characters (letters, digits, and -._~)
24+
# as safe, so we only need to add sub-delims, ':', and '@'.
25+
# Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
26+
return quote(value, safe="!$&'()*+,;=:@")
27+
28+
29+
def _quote_query_part(value: str) -> str:
30+
"""Percent-encode `value` for use in a URI query string.
31+
32+
Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
33+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
34+
"""
35+
return quote(value, safe="!$'()*+,;:@/?")
36+
37+
38+
def _quote_fragment_part(value: str) -> str:
39+
"""Percent-encode `value` for use in a URI fragment.
40+
41+
Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
42+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
43+
"""
44+
return quote(value, safe="!$&'()*+,;=:@/?")
45+
46+
47+
def _interpolate(
48+
template: str,
49+
values: Mapping[str, Any],
50+
quoter: Callable[[str], str],
51+
) -> str:
52+
"""Replace {name} placeholders in `template`, quoting each value with `quoter`.
53+
54+
Placeholder names are looked up in `values`.
55+
56+
Raises:
57+
KeyError: If a placeholder is not found in `values`.
58+
"""
59+
# re.split with a capturing group returns alternating
60+
# [text, name, text, name, ..., text] elements.
61+
parts = _PLACEHOLDER_RE.split(template)
62+
63+
for i in range(1, len(parts), 2):
64+
name = parts[i]
65+
if name not in values:
66+
raise KeyError(f"a value for placeholder {{{name}}} was not provided")
67+
val = values[name]
68+
if val is None:
69+
parts[i] = "null"
70+
elif isinstance(val, bool):
71+
parts[i] = "true" if val else "false"
72+
else:
73+
parts[i] = quoter(str(values[name]))
74+
75+
return "".join(parts)
76+
77+
78+
def path_template(template: str, /, **kwargs: Any) -> str:
79+
"""Interpolate {name} placeholders in `template` from keyword arguments.
80+
81+
Args:
82+
template: The template string containing {name} placeholders.
83+
**kwargs: Keyword arguments to interpolate into the template.
84+
85+
Returns:
86+
The template with placeholders interpolated and percent-encoded.
87+
88+
Safe characters for percent-encoding are dependent on the URI component.
89+
Placeholders in path and fragment portions are percent-encoded where the `segment`
90+
and `fragment` sets from RFC 3986 respectively are considered safe.
91+
Placeholders in the query portion are percent-encoded where the `query` set from
92+
RFC 3986 §3.3 is considered safe except for = and & characters.
93+
94+
Raises:
95+
KeyError: If a placeholder is not found in `kwargs`.
96+
ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
97+
"""
98+
# Split the template into path, query, and fragment portions.
99+
fragment_template: str | None = None
100+
query_template: str | None = None
101+
102+
rest = template
103+
if "#" in rest:
104+
rest, fragment_template = rest.split("#", 1)
105+
if "?" in rest:
106+
rest, query_template = rest.split("?", 1)
107+
path_template = rest
108+
109+
# Interpolate each portion with the appropriate quoting rules.
110+
path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
111+
112+
# Reject dot-segments (. and ..) in the final assembled path. The check
113+
# runs after interpolation so that adjacent placeholders or a mix of static
114+
# text and placeholders that together form a dot-segment are caught.
115+
# Also reject percent-encoded dot-segments to protect against incorrectly
116+
# implemented normalization in servers/proxies.
117+
for segment in path_result.split("/"):
118+
if _DOT_SEGMENT_RE.match(segment):
119+
raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
120+
121+
result = path_result
122+
if query_template is not None:
123+
result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
124+
if fragment_template is not None:
125+
result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
126+
127+
return result

src/beeper_desktop_api/resources/accounts/contacts.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import httpx
88

99
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
10-
from ..._utils import maybe_transform, async_maybe_transform
10+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1111
from ..._compat import cached_property
1212
from ..._resource import SyncAPIResource, AsyncAPIResource
1313
from ..._response import (
@@ -88,7 +88,7 @@ def list(
8888
if not account_id:
8989
raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}")
9090
return self._get_api_list(
91-
f"/v1/accounts/{account_id}/contacts/list",
91+
path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id),
9292
page=SyncCursorSearch[User],
9393
options=make_request_options(
9494
extra_headers=extra_headers,
@@ -140,7 +140,7 @@ def search(
140140
if not account_id:
141141
raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}")
142142
return self._get(
143-
f"/v1/accounts/{account_id}/contacts",
143+
path_template("/v1/accounts/{account_id}/contacts", account_id=account_id),
144144
options=make_request_options(
145145
extra_headers=extra_headers,
146146
extra_query=extra_query,
@@ -215,7 +215,7 @@ def list(
215215
if not account_id:
216216
raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}")
217217
return self._get_api_list(
218-
f"/v1/accounts/{account_id}/contacts/list",
218+
path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id),
219219
page=AsyncCursorSearch[User],
220220
options=make_request_options(
221221
extra_headers=extra_headers,
@@ -267,7 +267,7 @@ async def search(
267267
if not account_id:
268268
raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}")
269269
return await self._get(
270-
f"/v1/accounts/{account_id}/contacts",
270+
path_template("/v1/accounts/{account_id}/contacts", account_id=account_id),
271271
options=make_request_options(
272272
extra_headers=extra_headers,
273273
extra_query=extra_query,

src/beeper_desktop_api/resources/chats/chats.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ...types import chat_list_params, chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params
1212
from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given
13-
from ..._utils import maybe_transform, async_maybe_transform
13+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1414
from ..._compat import cached_property
1515
from .reminders import (
1616
RemindersResource,
@@ -180,7 +180,7 @@ def retrieve(
180180
if not chat_id:
181181
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
182182
return self._get(
183-
f"/v1/chats/{chat_id}",
183+
path_template("/v1/chats/{chat_id}", chat_id=chat_id),
184184
options=make_request_options(
185185
extra_headers=extra_headers,
186186
extra_query=extra_query,
@@ -281,7 +281,7 @@ def archive(
281281
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
282282
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
283283
return self._post(
284-
f"/v1/chats/{chat_id}/archive",
284+
path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id),
285285
body=maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams),
286286
options=make_request_options(
287287
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -523,7 +523,7 @@ async def retrieve(
523523
if not chat_id:
524524
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
525525
return await self._get(
526-
f"/v1/chats/{chat_id}",
526+
path_template("/v1/chats/{chat_id}", chat_id=chat_id),
527527
options=make_request_options(
528528
extra_headers=extra_headers,
529529
extra_query=extra_query,
@@ -624,7 +624,7 @@ async def archive(
624624
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
625625
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
626626
return await self._post(
627-
f"/v1/chats/{chat_id}/archive",
627+
path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id),
628628
body=await async_maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams),
629629
options=make_request_options(
630630
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

src/beeper_desktop_api/resources/chats/messages/reactions.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import httpx
66

77
from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
8-
from ...._utils import maybe_transform, async_maybe_transform
8+
from ...._utils import path_template, maybe_transform, async_maybe_transform
99
from ...._compat import cached_property
1010
from ...._resource import SyncAPIResource, AsyncAPIResource
1111
from ...._response import (
@@ -78,7 +78,9 @@ def delete(
7878
if not message_id:
7979
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
8080
return self._delete(
81-
f"/v1/chats/{chat_id}/messages/{message_id}/reactions",
81+
path_template(
82+
"/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id
83+
),
8284
options=make_request_options(
8385
extra_headers=extra_headers,
8486
extra_query=extra_query,
@@ -126,7 +128,9 @@ def add(
126128
if not message_id:
127129
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
128130
return self._post(
129-
f"/v1/chats/{chat_id}/messages/{message_id}/reactions",
131+
path_template(
132+
"/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id
133+
),
130134
body=maybe_transform(
131135
{
132136
"reaction_key": reaction_key,
@@ -197,7 +201,9 @@ async def delete(
197201
if not message_id:
198202
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
199203
return await self._delete(
200-
f"/v1/chats/{chat_id}/messages/{message_id}/reactions",
204+
path_template(
205+
"/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id
206+
),
201207
options=make_request_options(
202208
extra_headers=extra_headers,
203209
extra_query=extra_query,
@@ -247,7 +253,9 @@ async def add(
247253
if not message_id:
248254
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
249255
return await self._post(
250-
f"/v1/chats/{chat_id}/messages/{message_id}/reactions",
256+
path_template(
257+
"/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id
258+
),
251259
body=await async_maybe_transform(
252260
{
253261
"reaction_key": reaction_key,

src/beeper_desktop_api/resources/chats/reminders.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import httpx
66

77
from ..._types import Body, Query, Headers, NoneType, NotGiven, not_given
8-
from ..._utils import maybe_transform, async_maybe_transform
8+
from ..._utils import path_template, maybe_transform, async_maybe_transform
99
from ..._compat import cached_property
1010
from ..._resource import SyncAPIResource, AsyncAPIResource
1111
from ..._response import (
@@ -74,7 +74,7 @@ def create(
7474
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
7575
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
7676
return self._post(
77-
f"/v1/chats/{chat_id}/reminders",
77+
path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id),
7878
body=maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams),
7979
options=make_request_options(
8080
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -111,7 +111,7 @@ def delete(
111111
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
112112
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
113113
return self._delete(
114-
f"/v1/chats/{chat_id}/reminders",
114+
path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id),
115115
options=make_request_options(
116116
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
117117
),
@@ -173,7 +173,7 @@ async def create(
173173
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
174174
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
175175
return await self._post(
176-
f"/v1/chats/{chat_id}/reminders",
176+
path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id),
177177
body=await async_maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams),
178178
options=make_request_options(
179179
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -210,7 +210,7 @@ async def delete(
210210
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
211211
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
212212
return await self._delete(
213-
f"/v1/chats/{chat_id}/reminders",
213+
path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id),
214214
options=make_request_options(
215215
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
216216
),

src/beeper_desktop_api/resources/messages.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ..types import message_list_params, message_send_params, message_search_params, message_update_params
1212
from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
13-
from .._utils import maybe_transform, async_maybe_transform
13+
from .._utils import path_template, maybe_transform, async_maybe_transform
1414
from .._compat import cached_property
1515
from .._resource import SyncAPIResource, AsyncAPIResource
1616
from .._response import (
@@ -86,7 +86,7 @@ def update(
8686
if not message_id:
8787
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
8888
return self._put(
89-
f"/v1/chats/{chat_id}/messages/{message_id}",
89+
path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id),
9090
body=maybe_transform({"text": text}, message_update_params.MessageUpdateParams),
9191
options=make_request_options(
9292
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -130,7 +130,7 @@ def list(
130130
if not chat_id:
131131
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
132132
return self._get_api_list(
133-
f"/v1/chats/{chat_id}/messages",
133+
path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
134134
page=SyncCursorSortKey[Message],
135135
options=make_request_options(
136136
extra_headers=extra_headers,
@@ -288,7 +288,7 @@ def send(
288288
if not chat_id:
289289
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
290290
return self._post(
291-
f"/v1/chats/{chat_id}/messages",
291+
path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
292292
body=maybe_transform(
293293
{
294294
"attachment": attachment,
@@ -362,7 +362,7 @@ async def update(
362362
if not message_id:
363363
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
364364
return await self._put(
365-
f"/v1/chats/{chat_id}/messages/{message_id}",
365+
path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id),
366366
body=await async_maybe_transform({"text": text}, message_update_params.MessageUpdateParams),
367367
options=make_request_options(
368368
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -406,7 +406,7 @@ def list(
406406
if not chat_id:
407407
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
408408
return self._get_api_list(
409-
f"/v1/chats/{chat_id}/messages",
409+
path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
410410
page=AsyncCursorSortKey[Message],
411411
options=make_request_options(
412412
extra_headers=extra_headers,
@@ -564,7 +564,7 @@ async def send(
564564
if not chat_id:
565565
raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}")
566566
return await self._post(
567-
f"/v1/chats/{chat_id}/messages",
567+
path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
568568
body=await async_maybe_transform(
569569
{
570570
"attachment": attachment,

0 commit comments

Comments
 (0)