Skip to content

Commit abb806c

Browse files
fix: sanitize endpoint path params
1 parent 35a8acc commit abb806c

File tree

9 files changed

+267
-50
lines changed

9 files changed

+267
-50
lines changed

src/steel/_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/steel/_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/steel/resources/extensions.py

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

99
from ..types import extension_update_params, extension_upload_params
1010
from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
11-
from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
11+
from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform
1212
from .._compat import cached_property
1313
from .._resource import SyncAPIResource, AsyncAPIResource
1414
from .._response import (
@@ -91,7 +91,7 @@ def update(
9191
# multipart/form-data; boundary=---abc--
9292
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
9393
return self._put(
94-
f"/v1/extensions/{extension_id}",
94+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
9595
body=maybe_transform(body, extension_update_params.ExtensionUpdateParams),
9696
files=files,
9797
options=make_request_options(
@@ -145,7 +145,7 @@ def delete(
145145
if not extension_id:
146146
raise ValueError(f"Expected a non-empty value for `extension_id` but received {extension_id!r}")
147147
return self._delete(
148-
f"/v1/extensions/{extension_id}",
148+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
149149
options=make_request_options(
150150
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
151151
),
@@ -197,7 +197,7 @@ def download(
197197
if not extension_id:
198198
raise ValueError(f"Expected a non-empty value for `extension_id` but received {extension_id!r}")
199199
return self._get(
200-
f"/v1/extensions/{extension_id}",
200+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
201201
options=make_request_options(
202202
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
203203
),
@@ -319,7 +319,7 @@ async def update(
319319
# multipart/form-data; boundary=---abc--
320320
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
321321
return await self._put(
322-
f"/v1/extensions/{extension_id}",
322+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
323323
body=await async_maybe_transform(body, extension_update_params.ExtensionUpdateParams),
324324
files=files,
325325
options=make_request_options(
@@ -373,7 +373,7 @@ async def delete(
373373
if not extension_id:
374374
raise ValueError(f"Expected a non-empty value for `extension_id` but received {extension_id!r}")
375375
return await self._delete(
376-
f"/v1/extensions/{extension_id}",
376+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
377377
options=make_request_options(
378378
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
379379
),
@@ -425,7 +425,7 @@ async def download(
425425
if not extension_id:
426426
raise ValueError(f"Expected a non-empty value for `extension_id` but received {extension_id!r}")
427427
return await self._get(
428-
f"/v1/extensions/{extension_id}",
428+
path_template("/v1/extensions/{extension_id}", extension_id=extension_id),
429429
options=make_request_options(
430430
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
431431
),

src/steel/resources/files.py

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

99
from ..types import file_upload_params
1010
from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given
11-
from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
11+
from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform
1212
from .._compat import cached_property
1313
from .._resource import SyncAPIResource, AsyncAPIResource
1414
from .._response import (
@@ -98,7 +98,7 @@ def delete(
9898
raise ValueError(f"Expected a non-empty value for `path` but received {path!r}")
9999
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
100100
return self._delete(
101-
f"/v1/files/{path}",
101+
path_template("/v1/files/{path}", path=path),
102102
options=make_request_options(
103103
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
104104
),
@@ -132,7 +132,7 @@ def download(
132132
raise ValueError(f"Expected a non-empty value for `path` but received {path!r}")
133133
extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
134134
return self._get(
135-
f"/v1/files/{path}",
135+
path_template("/v1/files/{path}", path=path),
136136
options=make_request_options(
137137
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
138138
),
@@ -257,7 +257,7 @@ async def delete(
257257
raise ValueError(f"Expected a non-empty value for `path` but received {path!r}")
258258
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
259259
return await self._delete(
260-
f"/v1/files/{path}",
260+
path_template("/v1/files/{path}", path=path),
261261
options=make_request_options(
262262
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
263263
),
@@ -291,7 +291,7 @@ async def download(
291291
raise ValueError(f"Expected a non-empty value for `path` but received {path!r}")
292292
extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
293293
return await self._get(
294-
f"/v1/files/{path}",
294+
path_template("/v1/files/{path}", path=path),
295295
options=make_request_options(
296296
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
297297
),

src/steel/resources/profiles.py

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

99
from ..types import profile_create_params, profile_update_params
1010
from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
11-
from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
11+
from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform
1212
from .._compat import cached_property
1313
from .._resource import SyncAPIResource, AsyncAPIResource
1414
from .._response import (
@@ -154,7 +154,7 @@ def update(
154154
# multipart/form-data; boundary=---abc--
155155
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
156156
return self._patch(
157-
f"/v1/profiles/{id}",
157+
path_template("/v1/profiles/{id}", id=id),
158158
body=maybe_transform(body, profile_update_params.ProfileUpdateParams),
159159
files=files,
160160
options=make_request_options(
@@ -208,7 +208,7 @@ def get(
208208
if not id:
209209
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
210210
return self._get(
211-
f"/v1/profiles/{id}",
211+
path_template("/v1/profiles/{id}", id=id),
212212
options=make_request_options(
213213
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
214214
),
@@ -344,7 +344,7 @@ async def update(
344344
# multipart/form-data; boundary=---abc--
345345
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
346346
return await self._patch(
347-
f"/v1/profiles/{id}",
347+
path_template("/v1/profiles/{id}", id=id),
348348
body=await async_maybe_transform(body, profile_update_params.ProfileUpdateParams),
349349
files=files,
350350
options=make_request_options(
@@ -398,7 +398,7 @@ async def get(
398398
if not id:
399399
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
400400
return await self._get(
401-
f"/v1/profiles/{id}",
401+
path_template("/v1/profiles/{id}", id=id),
402402
options=make_request_options(
403403
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
404404
),

src/steel/resources/sessions/captchas.py

Lines changed: 7 additions & 7 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 (
@@ -81,7 +81,7 @@ def solve(
8181
if not session_id:
8282
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
8383
return self._post(
84-
f"/v1/sessions/{session_id}/captchas/solve",
84+
path_template("/v1/sessions/{session_id}/captchas/solve", session_id=session_id),
8585
body=maybe_transform(
8686
{
8787
"page_id": page_id,
@@ -131,7 +131,7 @@ def solve_image(
131131
if not session_id:
132132
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
133133
return self._post(
134-
f"/v1/sessions/{session_id}/captchas/solve-image",
134+
path_template("/v1/sessions/{session_id}/captchas/solve-image", session_id=session_id),
135135
body=maybe_transform(
136136
{
137137
"image_x_path": image_x_path,
@@ -172,7 +172,7 @@ def status(
172172
if not session_id:
173173
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
174174
return self._get(
175-
f"/v1/sessions/{session_id}/captchas/status",
175+
path_template("/v1/sessions/{session_id}/captchas/status", session_id=session_id),
176176
options=make_request_options(
177177
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
178178
),
@@ -238,7 +238,7 @@ async def solve(
238238
if not session_id:
239239
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
240240
return await self._post(
241-
f"/v1/sessions/{session_id}/captchas/solve",
241+
path_template("/v1/sessions/{session_id}/captchas/solve", session_id=session_id),
242242
body=await async_maybe_transform(
243243
{
244244
"page_id": page_id,
@@ -288,7 +288,7 @@ async def solve_image(
288288
if not session_id:
289289
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
290290
return await self._post(
291-
f"/v1/sessions/{session_id}/captchas/solve-image",
291+
path_template("/v1/sessions/{session_id}/captchas/solve-image", session_id=session_id),
292292
body=await async_maybe_transform(
293293
{
294294
"image_x_path": image_x_path,
@@ -329,7 +329,7 @@ async def status(
329329
if not session_id:
330330
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
331331
return await self._get(
332-
f"/v1/sessions/{session_id}/captchas/status",
332+
path_template("/v1/sessions/{session_id}/captchas/status", session_id=session_id),
333333
options=make_request_options(
334334
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
335335
),

0 commit comments

Comments
 (0)