Skip to content

Commit 9b55d2b

Browse files
fix: sanitize endpoint path params
1 parent cda8f94 commit 9b55d2b

File tree

20 files changed

+394
-177
lines changed

20 files changed

+394
-177
lines changed

src/kernel/_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/kernel/_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/kernel/resources/auth/connections.py

Lines changed: 11 additions & 11 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, NoneType, NotGiven, SequenceNotStr, 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 (
@@ -179,7 +179,7 @@ def retrieve(
179179
if not id:
180180
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
181181
return self._get(
182-
f"/auth/connections/{id}",
182+
path_template("/auth/connections/{id}", id=id),
183183
options=make_request_options(
184184
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
185185
),
@@ -273,7 +273,7 @@ def delete(
273273
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
274274
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
275275
return self._delete(
276-
f"/auth/connections/{id}",
276+
path_template("/auth/connections/{id}", id=id),
277277
options=make_request_options(
278278
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
279279
),
@@ -309,7 +309,7 @@ def follow(
309309
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
310310
extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})}
311311
return self._get(
312-
f"/auth/connections/{id}/events",
312+
path_template("/auth/connections/{id}/events", id=id),
313313
options=make_request_options(
314314
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
315315
),
@@ -353,7 +353,7 @@ def login(
353353
if not id:
354354
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
355355
return self._post(
356-
f"/auth/connections/{id}/login",
356+
path_template("/auth/connections/{id}/login", id=id),
357357
body=maybe_transform({"proxy": proxy}, connection_login_params.ConnectionLoginParams),
358358
options=make_request_options(
359359
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -398,7 +398,7 @@ def submit(
398398
if not id:
399399
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
400400
return self._post(
401-
f"/auth/connections/{id}/submit",
401+
path_template("/auth/connections/{id}/submit", id=id),
402402
body=maybe_transform(
403403
{
404404
"fields": fields,
@@ -560,7 +560,7 @@ async def retrieve(
560560
if not id:
561561
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
562562
return await self._get(
563-
f"/auth/connections/{id}",
563+
path_template("/auth/connections/{id}", id=id),
564564
options=make_request_options(
565565
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
566566
),
@@ -654,7 +654,7 @@ async def delete(
654654
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
655655
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
656656
return await self._delete(
657-
f"/auth/connections/{id}",
657+
path_template("/auth/connections/{id}", id=id),
658658
options=make_request_options(
659659
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
660660
),
@@ -690,7 +690,7 @@ async def follow(
690690
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
691691
extra_headers = {"Accept": "text/event-stream", **(extra_headers or {})}
692692
return await self._get(
693-
f"/auth/connections/{id}/events",
693+
path_template("/auth/connections/{id}/events", id=id),
694694
options=make_request_options(
695695
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
696696
),
@@ -734,7 +734,7 @@ async def login(
734734
if not id:
735735
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
736736
return await self._post(
737-
f"/auth/connections/{id}/login",
737+
path_template("/auth/connections/{id}/login", id=id),
738738
body=await async_maybe_transform({"proxy": proxy}, connection_login_params.ConnectionLoginParams),
739739
options=make_request_options(
740740
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -779,7 +779,7 @@ async def submit(
779779
if not id:
780780
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
781781
return await self._post(
782-
f"/auth/connections/{id}/submit",
782+
path_template("/auth/connections/{id}/submit", id=id),
783783
body=await async_maybe_transform(
784784
{
785785
"fields": fields,

src/kernel/resources/browser_pools.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
browser_pool_release_params,
1515
)
1616
from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given
17-
from .._utils import maybe_transform, async_maybe_transform
17+
from .._utils import path_template, maybe_transform, async_maybe_transform
1818
from .._compat import cached_property
1919
from .._resource import SyncAPIResource, AsyncAPIResource
2020
from .._response import (
@@ -180,7 +180,7 @@ def retrieve(
180180
if not id_or_name:
181181
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
182182
return self._get(
183-
f"/browser_pools/{id_or_name}",
183+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
184184
options=make_request_options(
185185
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
186186
),
@@ -269,7 +269,7 @@ def update(
269269
if not id_or_name:
270270
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
271271
return self._patch(
272-
f"/browser_pools/{id_or_name}",
272+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
273273
body=maybe_transform(
274274
{
275275
"size": size,
@@ -345,7 +345,7 @@ def delete(
345345
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
346346
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
347347
return self._delete(
348-
f"/browser_pools/{id_or_name}",
348+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
349349
body=maybe_transform({"force": force}, browser_pool_delete_params.BrowserPoolDeleteParams),
350350
options=make_request_options(
351351
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -388,7 +388,7 @@ def acquire(
388388
if not id_or_name:
389389
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
390390
return self._post(
391-
f"/browser_pools/{id_or_name}/acquire",
391+
path_template("/browser_pools/{id_or_name}/acquire", id_or_name=id_or_name),
392392
body=maybe_transform(
393393
{"acquire_timeout_seconds": acquire_timeout_seconds},
394394
browser_pool_acquire_params.BrowserPoolAcquireParams,
@@ -426,7 +426,7 @@ def flush(
426426
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
427427
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
428428
return self._post(
429-
f"/browser_pools/{id_or_name}/flush",
429+
path_template("/browser_pools/{id_or_name}/flush", id_or_name=id_or_name),
430430
options=make_request_options(
431431
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
432432
),
@@ -467,7 +467,7 @@ def release(
467467
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
468468
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
469469
return self._post(
470-
f"/browser_pools/{id_or_name}/release",
470+
path_template("/browser_pools/{id_or_name}/release", id_or_name=id_or_name),
471471
body=maybe_transform(
472472
{
473473
"session_id": session_id,
@@ -628,7 +628,7 @@ async def retrieve(
628628
if not id_or_name:
629629
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
630630
return await self._get(
631-
f"/browser_pools/{id_or_name}",
631+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
632632
options=make_request_options(
633633
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
634634
),
@@ -717,7 +717,7 @@ async def update(
717717
if not id_or_name:
718718
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
719719
return await self._patch(
720-
f"/browser_pools/{id_or_name}",
720+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
721721
body=await async_maybe_transform(
722722
{
723723
"size": size,
@@ -793,7 +793,7 @@ async def delete(
793793
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
794794
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
795795
return await self._delete(
796-
f"/browser_pools/{id_or_name}",
796+
path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name),
797797
body=await async_maybe_transform({"force": force}, browser_pool_delete_params.BrowserPoolDeleteParams),
798798
options=make_request_options(
799799
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -836,7 +836,7 @@ async def acquire(
836836
if not id_or_name:
837837
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
838838
return await self._post(
839-
f"/browser_pools/{id_or_name}/acquire",
839+
path_template("/browser_pools/{id_or_name}/acquire", id_or_name=id_or_name),
840840
body=await async_maybe_transform(
841841
{"acquire_timeout_seconds": acquire_timeout_seconds},
842842
browser_pool_acquire_params.BrowserPoolAcquireParams,
@@ -874,7 +874,7 @@ async def flush(
874874
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
875875
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
876876
return await self._post(
877-
f"/browser_pools/{id_or_name}/flush",
877+
path_template("/browser_pools/{id_or_name}/flush", id_or_name=id_or_name),
878878
options=make_request_options(
879879
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
880880
),
@@ -915,7 +915,7 @@ async def release(
915915
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
916916
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
917917
return await self._post(
918-
f"/browser_pools/{id_or_name}/release",
918+
path_template("/browser_pools/{id_or_name}/release", id_or_name=id_or_name),
919919
body=await async_maybe_transform(
920920
{
921921
"session_id": session_id,

0 commit comments

Comments
 (0)