Skip to content

Commit 0993026

Browse files
fix: sanitize endpoint path params
1 parent e565324 commit 0993026

21 files changed

Lines changed: 391 additions & 167 deletions
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/runloop_api_client/resources/agents.py

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

99
from ..types import agent_list_params, agent_create_params
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 (
@@ -127,7 +127,7 @@ def retrieve(
127127
if not id:
128128
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
129129
return self._get(
130-
f"/v1/agents/{id}",
130+
path_template("/v1/agents/{id}", id=id),
131131
options=make_request_options(
132132
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
133133
),
@@ -300,7 +300,7 @@ async def retrieve(
300300
if not id:
301301
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
302302
return await self._get(
303-
f"/v1/agents/{id}",
303+
path_template("/v1/agents/{id}", id=id),
304304
options=make_request_options(
305305
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
306306
),

src/runloop_api_client/resources/benchmark_jobs.py

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

99
from ..types import benchmark_job_list_params, benchmark_job_create_params
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 (
@@ -121,7 +121,7 @@ def retrieve(
121121
if not id:
122122
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
123123
return self._get(
124-
f"/v1/benchmark_jobs/{id}",
124+
path_template("/v1/benchmark_jobs/{id}", id=id),
125125
options=make_request_options(
126126
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
127127
),
@@ -276,7 +276,7 @@ async def retrieve(
276276
if not id:
277277
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
278278
return await self._get(
279-
f"/v1/benchmark_jobs/{id}",
279+
path_template("/v1/benchmark_jobs/{id}", id=id),
280280
options=make_request_options(
281281
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
282282
),

src/runloop_api_client/resources/benchmark_runs.py

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

99
from ..types import benchmark_run_list_params, benchmark_run_list_scenario_runs_params
1010
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
11-
from .._utils import maybe_transform
11+
from .._utils import path_template, maybe_transform
1212
from .._compat import cached_property
1313
from .._resource import SyncAPIResource, AsyncAPIResource
1414
from .._response import (
@@ -71,7 +71,7 @@ def retrieve(
7171
if not id:
7272
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
7373
return self._get(
74-
f"/v1/benchmark_runs/{id}",
74+
path_template("/v1/benchmark_runs/{id}", id=id),
7575
options=make_request_options(
7676
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7777
),
@@ -165,7 +165,7 @@ def cancel(
165165
if not id:
166166
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
167167
return self._post(
168-
f"/v1/benchmark_runs/{id}/cancel",
168+
path_template("/v1/benchmark_runs/{id}/cancel", id=id),
169169
options=make_request_options(
170170
extra_headers=extra_headers,
171171
extra_query=extra_query,
@@ -205,7 +205,7 @@ def complete(
205205
if not id:
206206
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
207207
return self._post(
208-
f"/v1/benchmark_runs/{id}/complete",
208+
path_template("/v1/benchmark_runs/{id}/complete", id=id),
209209
options=make_request_options(
210210
extra_headers=extra_headers,
211211
extra_query=extra_query,
@@ -251,7 +251,7 @@ def list_scenario_runs(
251251
if not id:
252252
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
253253
return self._get_api_list(
254-
f"/v1/benchmark_runs/{id}/scenario_runs",
254+
path_template("/v1/benchmark_runs/{id}/scenario_runs", id=id),
255255
page=SyncBenchmarkRunsCursorIDPage[ScenarioRunView],
256256
options=make_request_options(
257257
extra_headers=extra_headers,
@@ -317,7 +317,7 @@ async def retrieve(
317317
if not id:
318318
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
319319
return await self._get(
320-
f"/v1/benchmark_runs/{id}",
320+
path_template("/v1/benchmark_runs/{id}", id=id),
321321
options=make_request_options(
322322
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
323323
),
@@ -411,7 +411,7 @@ async def cancel(
411411
if not id:
412412
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
413413
return await self._post(
414-
f"/v1/benchmark_runs/{id}/cancel",
414+
path_template("/v1/benchmark_runs/{id}/cancel", id=id),
415415
options=make_request_options(
416416
extra_headers=extra_headers,
417417
extra_query=extra_query,
@@ -451,7 +451,7 @@ async def complete(
451451
if not id:
452452
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
453453
return await self._post(
454-
f"/v1/benchmark_runs/{id}/complete",
454+
path_template("/v1/benchmark_runs/{id}/complete", id=id),
455455
options=make_request_options(
456456
extra_headers=extra_headers,
457457
extra_query=extra_query,
@@ -497,7 +497,7 @@ def list_scenario_runs(
497497
if not id:
498498
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
499499
return self._get_api_list(
500-
f"/v1/benchmark_runs/{id}/scenario_runs",
500+
path_template("/v1/benchmark_runs/{id}/scenario_runs", id=id),
501501
page=AsyncBenchmarkRunsCursorIDPage[ScenarioRunView],
502502
options=make_request_options(
503503
extra_headers=extra_headers,

src/runloop_api_client/resources/benchmarks.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
benchmark_update_scenarios_params,
1717
)
1818
from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
19-
from .._utils import maybe_transform, async_maybe_transform
19+
from .._utils import path_template, maybe_transform, async_maybe_transform
2020
from .._compat import cached_property
2121
from .._resource import SyncAPIResource, AsyncAPIResource
2222
from .._response import (
@@ -154,7 +154,7 @@ def retrieve(
154154
if not id:
155155
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
156156
return self._get(
157-
f"/v1/benchmarks/{id}",
157+
path_template("/v1/benchmarks/{id}", id=id),
158158
options=make_request_options(
159159
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
160160
),
@@ -218,7 +218,7 @@ def update(
218218
if not id:
219219
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
220220
return self._post(
221-
f"/v1/benchmarks/{id}",
221+
path_template("/v1/benchmarks/{id}", id=id),
222222
body=maybe_transform(
223223
{
224224
"attribution": attribution,
@@ -324,7 +324,7 @@ def definitions(
324324
if not id:
325325
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
326326
return self._get(
327-
f"/v1/benchmarks/{id}/definitions",
327+
path_template("/v1/benchmarks/{id}/definitions", id=id),
328328
options=make_request_options(
329329
extra_headers=extra_headers,
330330
extra_query=extra_query,
@@ -481,7 +481,7 @@ def update_scenarios(
481481
if not id:
482482
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
483483
return self._post(
484-
f"/v1/benchmarks/{id}/scenarios",
484+
path_template("/v1/benchmarks/{id}/scenarios", id=id),
485485
body=maybe_transform(
486486
{
487487
"scenarios_to_add": scenarios_to_add,
@@ -619,7 +619,7 @@ async def retrieve(
619619
if not id:
620620
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
621621
return await self._get(
622-
f"/v1/benchmarks/{id}",
622+
path_template("/v1/benchmarks/{id}", id=id),
623623
options=make_request_options(
624624
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
625625
),
@@ -683,7 +683,7 @@ async def update(
683683
if not id:
684684
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
685685
return await self._post(
686-
f"/v1/benchmarks/{id}",
686+
path_template("/v1/benchmarks/{id}", id=id),
687687
body=await async_maybe_transform(
688688
{
689689
"attribution": attribution,
@@ -789,7 +789,7 @@ async def definitions(
789789
if not id:
790790
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
791791
return await self._get(
792-
f"/v1/benchmarks/{id}/definitions",
792+
path_template("/v1/benchmarks/{id}/definitions", id=id),
793793
options=make_request_options(
794794
extra_headers=extra_headers,
795795
extra_query=extra_query,
@@ -946,7 +946,7 @@ async def update_scenarios(
946946
if not id:
947947
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
948948
return await self._post(
949-
f"/v1/benchmarks/{id}/scenarios",
949+
path_template("/v1/benchmarks/{id}/scenarios", id=id),
950950
body=await async_maybe_transform(
951951
{
952952
"scenarios_to_add": scenarios_to_add,

0 commit comments

Comments
 (0)