Skip to content

Commit d4ca0b1

Browse files
fix: sanitize endpoint path params
1 parent a50b7f8 commit d4ca0b1

13 files changed

Lines changed: 359 additions & 98 deletions

File tree

src/ark/_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 (

src/ark/_utils/_path.py

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/ark/resources/emails.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
email_send_batch_params,
1616
)
1717
from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
18-
from .._utils import maybe_transform, strip_not_given, async_maybe_transform
18+
from .._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform
1919
from .._compat import cached_property
2020
from .._resource import SyncAPIResource, AsyncAPIResource
2121
from .._response import (
@@ -109,7 +109,7 @@ def retrieve(
109109
if not email_id:
110110
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
111111
return self._get(
112-
f"/emails/{email_id}",
112+
path_template("/emails/{email_id}", email_id=email_id),
113113
options=make_request_options(
114114
extra_headers=extra_headers,
115115
extra_query=extra_query,
@@ -269,7 +269,7 @@ def retrieve_deliveries(
269269
if not email_id:
270270
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
271271
return self._get(
272-
f"/emails/{email_id}/deliveries",
272+
path_template("/emails/{email_id}/deliveries", email_id=email_id),
273273
options=make_request_options(
274274
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
275275
),
@@ -306,7 +306,7 @@ def retry(
306306
if not email_id:
307307
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
308308
return self._post(
309-
f"/emails/{email_id}/retry",
309+
path_template("/emails/{email_id}/retry", email_id=email_id),
310310
options=make_request_options(
311311
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
312312
),
@@ -665,7 +665,7 @@ async def retrieve(
665665
if not email_id:
666666
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
667667
return await self._get(
668-
f"/emails/{email_id}",
668+
path_template("/emails/{email_id}", email_id=email_id),
669669
options=make_request_options(
670670
extra_headers=extra_headers,
671671
extra_query=extra_query,
@@ -825,7 +825,7 @@ async def retrieve_deliveries(
825825
if not email_id:
826826
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
827827
return await self._get(
828-
f"/emails/{email_id}/deliveries",
828+
path_template("/emails/{email_id}/deliveries", email_id=email_id),
829829
options=make_request_options(
830830
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
831831
),
@@ -862,7 +862,7 @@ async def retry(
862862
if not email_id:
863863
raise ValueError(f"Expected a non-empty value for `email_id` but received {email_id!r}")
864864
return await self._post(
865-
f"/emails/{email_id}/retry",
865+
path_template("/emails/{email_id}/retry", email_id=email_id),
866866
options=make_request_options(
867867
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
868868
),

src/ark/resources/logs.py

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

1111
from ..types import log_list_params
1212
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
13-
from .._utils import maybe_transform
13+
from .._utils import path_template, maybe_transform
1414
from .._compat import cached_property
1515
from .._resource import SyncAPIResource, AsyncAPIResource
1616
from .._response import (
@@ -109,7 +109,7 @@ def retrieve(
109109
if not request_id:
110110
raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
111111
return self._get(
112-
f"/logs/{request_id}",
112+
path_template("/logs/{request_id}", request_id=request_id),
113113
options=make_request_options(
114114
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
115115
),
@@ -300,7 +300,7 @@ async def retrieve(
300300
if not request_id:
301301
raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
302302
return await self._get(
303-
f"/logs/{request_id}",
303+
path_template("/logs/{request_id}", request_id=request_id),
304304
options=make_request_options(
305305
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
306306
),

src/ark/resources/platform/webhooks.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99

1010
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
11-
from ..._utils import maybe_transform, async_maybe_transform
11+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import (
@@ -165,7 +165,7 @@ def retrieve(
165165
if not webhook_id:
166166
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
167167
return self._get(
168-
f"/platform/webhooks/{webhook_id}",
168+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
169169
options=make_request_options(
170170
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
171171
),
@@ -229,7 +229,7 @@ def update(
229229
if not webhook_id:
230230
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
231231
return self._patch(
232-
f"/platform/webhooks/{webhook_id}",
232+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
233233
body=maybe_transform(
234234
{
235235
"enabled": enabled,
@@ -298,7 +298,7 @@ def delete(
298298
if not webhook_id:
299299
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
300300
return self._delete(
301-
f"/platform/webhooks/{webhook_id}",
301+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
302302
options=make_request_options(
303303
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
304304
),
@@ -425,7 +425,7 @@ def replay_delivery(
425425
if not delivery_id:
426426
raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}")
427427
return self._post(
428-
f"/platform/webhooks/deliveries/{delivery_id}/replay",
428+
path_template("/platform/webhooks/deliveries/{delivery_id}/replay", delivery_id=delivery_id),
429429
options=make_request_options(
430430
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
431431
),
@@ -460,7 +460,7 @@ def retrieve_delivery(
460460
if not delivery_id:
461461
raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}")
462462
return self._get(
463-
f"/platform/webhooks/deliveries/{delivery_id}",
463+
path_template("/platform/webhooks/deliveries/{delivery_id}", delivery_id=delivery_id),
464464
options=make_request_options(
465465
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
466466
),
@@ -514,7 +514,7 @@ def test(
514514
if not webhook_id:
515515
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
516516
return self._post(
517-
f"/platform/webhooks/{webhook_id}/test",
517+
path_template("/platform/webhooks/{webhook_id}/test", webhook_id=webhook_id),
518518
body=maybe_transform({"event": event}, webhook_test_params.WebhookTestParams),
519519
options=make_request_options(
520520
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -650,7 +650,7 @@ async def retrieve(
650650
if not webhook_id:
651651
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
652652
return await self._get(
653-
f"/platform/webhooks/{webhook_id}",
653+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
654654
options=make_request_options(
655655
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
656656
),
@@ -714,7 +714,7 @@ async def update(
714714
if not webhook_id:
715715
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
716716
return await self._patch(
717-
f"/platform/webhooks/{webhook_id}",
717+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
718718
body=await async_maybe_transform(
719719
{
720720
"enabled": enabled,
@@ -783,7 +783,7 @@ async def delete(
783783
if not webhook_id:
784784
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
785785
return await self._delete(
786-
f"/platform/webhooks/{webhook_id}",
786+
path_template("/platform/webhooks/{webhook_id}", webhook_id=webhook_id),
787787
options=make_request_options(
788788
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
789789
),
@@ -910,7 +910,7 @@ async def replay_delivery(
910910
if not delivery_id:
911911
raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}")
912912
return await self._post(
913-
f"/platform/webhooks/deliveries/{delivery_id}/replay",
913+
path_template("/platform/webhooks/deliveries/{delivery_id}/replay", delivery_id=delivery_id),
914914
options=make_request_options(
915915
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
916916
),
@@ -945,7 +945,7 @@ async def retrieve_delivery(
945945
if not delivery_id:
946946
raise ValueError(f"Expected a non-empty value for `delivery_id` but received {delivery_id!r}")
947947
return await self._get(
948-
f"/platform/webhooks/deliveries/{delivery_id}",
948+
path_template("/platform/webhooks/deliveries/{delivery_id}", delivery_id=delivery_id),
949949
options=make_request_options(
950950
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
951951
),
@@ -999,7 +999,7 @@ async def test(
999999
if not webhook_id:
10001000
raise ValueError(f"Expected a non-empty value for `webhook_id` but received {webhook_id!r}")
10011001
return await self._post(
1002-
f"/platform/webhooks/{webhook_id}/test",
1002+
path_template("/platform/webhooks/{webhook_id}/test", webhook_id=webhook_id),
10031003
body=await async_maybe_transform({"event": event}, webhook_test_params.WebhookTestParams),
10041004
options=make_request_options(
10051005
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

0 commit comments

Comments
 (0)