Skip to content

Commit 9b288d5

Browse files
fix: sanitize endpoint path params
1 parent 94a14da commit 9b288d5

26 files changed

Lines changed: 443 additions & 138 deletions

File tree

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

src/llama_stack_client/resources/alpha/admin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import httpx
1515

1616
from ..._types import Body, Omit, Query, Headers, 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 (
@@ -102,7 +102,7 @@ def inspect_provider(
102102
if not provider_id:
103103
raise ValueError(f"Expected a non-empty value for `provider_id` but received {provider_id!r}")
104104
return self._get(
105-
f"/v1alpha/admin/providers/{provider_id}",
105+
path_template("/v1alpha/admin/providers/{provider_id}", provider_id=provider_id),
106106
options=make_request_options(
107107
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
108108
),
@@ -260,7 +260,7 @@ async def inspect_provider(
260260
if not provider_id:
261261
raise ValueError(f"Expected a non-empty value for `provider_id` but received {provider_id!r}")
262262
return await self._get(
263-
f"/v1alpha/admin/providers/{provider_id}",
263+
path_template("/v1alpha/admin/providers/{provider_id}", provider_id=provider_id),
264264
options=make_request_options(
265265
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
266266
),

src/llama_stack_client/resources/alpha/benchmarks.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import httpx
1515

1616
from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, 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 (
@@ -80,7 +80,7 @@ def retrieve(
8080
if not benchmark_id:
8181
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
8282
return self._get(
83-
f"/v1alpha/eval/benchmarks/{benchmark_id}",
83+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}", benchmark_id=benchmark_id),
8484
options=make_request_options(
8585
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8686
),
@@ -201,7 +201,7 @@ def unregister(
201201
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
202202
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
203203
return self._delete(
204-
f"/v1alpha/eval/benchmarks/{benchmark_id}",
204+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}", benchmark_id=benchmark_id),
205205
options=make_request_options(
206206
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
207207
),
@@ -257,7 +257,7 @@ async def retrieve(
257257
if not benchmark_id:
258258
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
259259
return await self._get(
260-
f"/v1alpha/eval/benchmarks/{benchmark_id}",
260+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}", benchmark_id=benchmark_id),
261261
options=make_request_options(
262262
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
263263
),
@@ -378,7 +378,7 @@ async def unregister(
378378
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
379379
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
380380
return await self._delete(
381-
f"/v1alpha/eval/benchmarks/{benchmark_id}",
381+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}", benchmark_id=benchmark_id),
382382
options=make_request_options(
383383
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
384384
),

src/llama_stack_client/resources/alpha/eval/eval.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
AsyncJobsResourceWithStreamingResponse,
2222
)
2323
from ...._types import Body, Query, Headers, NotGiven, SequenceNotStr, not_given
24-
from ...._utils import maybe_transform, async_maybe_transform
24+
from ...._utils import path_template, maybe_transform, async_maybe_transform
2525
from ...._compat import cached_property
2626
from ...._resource import SyncAPIResource, AsyncAPIResource
2727
from ...._response import (
@@ -112,7 +112,7 @@ def evaluate_rows(
112112
if not benchmark_id:
113113
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
114114
return self._post(
115-
f"/v1alpha/eval/benchmarks/{benchmark_id}/evaluations",
115+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/evaluations", benchmark_id=benchmark_id),
116116
body=maybe_transform(
117117
{
118118
"benchmark_config": benchmark_config,
@@ -164,7 +164,7 @@ def evaluate_rows_alpha(
164164
if not benchmark_id:
165165
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
166166
return self._post(
167-
f"/v1alpha/eval/benchmarks/{benchmark_id}/evaluations",
167+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/evaluations", benchmark_id=benchmark_id),
168168
body=maybe_transform(
169169
{
170170
"benchmark_config": benchmark_config,
@@ -210,7 +210,7 @@ def run_eval(
210210
if not benchmark_id:
211211
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
212212
return self._post(
213-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs",
213+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/jobs", benchmark_id=benchmark_id),
214214
body=maybe_transform({"benchmark_config": benchmark_config}, eval_run_eval_params.EvalRunEvalParams),
215215
options=make_request_options(
216216
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -249,7 +249,7 @@ def run_eval_alpha(
249249
if not benchmark_id:
250250
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
251251
return self._post(
252-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs",
252+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/jobs", benchmark_id=benchmark_id),
253253
body=maybe_transform(
254254
{"benchmark_config": benchmark_config}, eval_run_eval_alpha_params.EvalRunEvalAlphaParams
255255
),
@@ -328,7 +328,7 @@ async def evaluate_rows(
328328
if not benchmark_id:
329329
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
330330
return await self._post(
331-
f"/v1alpha/eval/benchmarks/{benchmark_id}/evaluations",
331+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/evaluations", benchmark_id=benchmark_id),
332332
body=await async_maybe_transform(
333333
{
334334
"benchmark_config": benchmark_config,
@@ -380,7 +380,7 @@ async def evaluate_rows_alpha(
380380
if not benchmark_id:
381381
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
382382
return await self._post(
383-
f"/v1alpha/eval/benchmarks/{benchmark_id}/evaluations",
383+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/evaluations", benchmark_id=benchmark_id),
384384
body=await async_maybe_transform(
385385
{
386386
"benchmark_config": benchmark_config,
@@ -426,7 +426,7 @@ async def run_eval(
426426
if not benchmark_id:
427427
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
428428
return await self._post(
429-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs",
429+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/jobs", benchmark_id=benchmark_id),
430430
body=await async_maybe_transform(
431431
{"benchmark_config": benchmark_config}, eval_run_eval_params.EvalRunEvalParams
432432
),
@@ -467,7 +467,7 @@ async def run_eval_alpha(
467467
if not benchmark_id:
468468
raise ValueError(f"Expected a non-empty value for `benchmark_id` but received {benchmark_id!r}")
469469
return await self._post(
470-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs",
470+
path_template("/v1alpha/eval/benchmarks/{benchmark_id}/jobs", benchmark_id=benchmark_id),
471471
body=await async_maybe_transform(
472472
{"benchmark_config": benchmark_config}, eval_run_eval_alpha_params.EvalRunEvalAlphaParams
473473
),

src/llama_stack_client/resources/alpha/eval/jobs.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import httpx
1212

1313
from ...._types import Body, Query, Headers, NoneType, NotGiven, not_given
14+
from ...._utils import path_template
1415
from ...._compat import cached_property
1516
from ...._resource import SyncAPIResource, AsyncAPIResource
1617
from ...._response import (
@@ -79,7 +80,9 @@ def retrieve(
7980
if not job_id:
8081
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
8182
return self._get(
82-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}/result",
83+
path_template(
84+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}/result", benchmark_id=benchmark_id, job_id=job_id
85+
),
8386
options=make_request_options(
8487
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8588
),
@@ -116,7 +119,9 @@ def cancel(
116119
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
117120
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
118121
return self._delete(
119-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}",
122+
path_template(
123+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}", benchmark_id=benchmark_id, job_id=job_id
124+
),
120125
options=make_request_options(
121126
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
122127
),
@@ -152,7 +157,9 @@ def status(
152157
if not job_id:
153158
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
154159
return self._get(
155-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}",
160+
path_template(
161+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}", benchmark_id=benchmark_id, job_id=job_id
162+
),
156163
options=make_request_options(
157164
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
158165
),
@@ -213,7 +220,9 @@ async def retrieve(
213220
if not job_id:
214221
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
215222
return await self._get(
216-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}/result",
223+
path_template(
224+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}/result", benchmark_id=benchmark_id, job_id=job_id
225+
),
217226
options=make_request_options(
218227
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
219228
),
@@ -250,7 +259,9 @@ async def cancel(
250259
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
251260
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
252261
return await self._delete(
253-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}",
262+
path_template(
263+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}", benchmark_id=benchmark_id, job_id=job_id
264+
),
254265
options=make_request_options(
255266
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
256267
),
@@ -286,7 +297,9 @@ async def status(
286297
if not job_id:
287298
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
288299
return await self._get(
289-
f"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}",
300+
path_template(
301+
"/v1alpha/eval/benchmarks/{benchmark_id}/jobs/{job_id}", benchmark_id=benchmark_id, job_id=job_id
302+
),
290303
options=make_request_options(
291304
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
292305
),

0 commit comments

Comments
 (0)