Skip to content

Commit dc9d512

Browse files
fix: sanitize endpoint path params
1 parent 7e02151 commit dc9d512

File tree

17 files changed

+301
-63
lines changed

17 files changed

+301
-63
lines changed

src/openlayer/_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/openlayer/_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/openlayer/resources/commits/commits.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import httpx
66

77
from ..._types import Body, Query, Headers, NotGiven, not_given
8+
from ..._utils import path_template
89
from ..._compat import cached_property
910
from ..._resource import SyncAPIResource, AsyncAPIResource
1011
from ..._response import (
@@ -77,7 +78,7 @@ def retrieve(
7778
if not project_version_id:
7879
raise ValueError(f"Expected a non-empty value for `project_version_id` but received {project_version_id!r}")
7980
return self._get(
80-
f"/versions/{project_version_id}",
81+
path_template("/versions/{project_version_id}", project_version_id=project_version_id),
8182
options=make_request_options(
8283
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8384
),
@@ -135,7 +136,7 @@ async def retrieve(
135136
if not project_version_id:
136137
raise ValueError(f"Expected a non-empty value for `project_version_id` but received {project_version_id!r}")
137138
return await self._get(
138-
f"/versions/{project_version_id}",
139+
path_template("/versions/{project_version_id}", project_version_id=project_version_id),
139140
options=make_request_options(
140141
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
141142
),

src/openlayer/resources/commits/test_results.py

Lines changed: 3 additions & 3 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, NotGiven, 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 (
@@ -88,7 +88,7 @@ def list(
8888
if not project_version_id:
8989
raise ValueError(f"Expected a non-empty value for `project_version_id` but received {project_version_id!r}")
9090
return self._get(
91-
f"/versions/{project_version_id}/results",
91+
path_template("/versions/{project_version_id}/results", project_version_id=project_version_id),
9292
options=make_request_options(
9393
extra_headers=extra_headers,
9494
extra_query=extra_query,
@@ -172,7 +172,7 @@ async def list(
172172
if not project_version_id:
173173
raise ValueError(f"Expected a non-empty value for `project_version_id` but received {project_version_id!r}")
174174
return await self._get(
175-
f"/versions/{project_version_id}/results",
175+
path_template("/versions/{project_version_id}/results", project_version_id=project_version_id),
176176
options=make_request_options(
177177
extra_headers=extra_headers,
178178
extra_query=extra_query,

src/openlayer/resources/inference_pipelines/data.py

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

99
from ..._types import Body, Query, Headers, NotGiven, 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 (
@@ -78,7 +78,9 @@ def stream(
7878
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
7979
)
8080
return self._post(
81-
f"/inference-pipelines/{inference_pipeline_id}/data-stream",
81+
path_template(
82+
"/inference-pipelines/{inference_pipeline_id}/data-stream", inference_pipeline_id=inference_pipeline_id
83+
),
8284
body=maybe_transform(
8385
{
8486
"config": config,
@@ -148,7 +150,9 @@ async def stream(
148150
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
149151
)
150152
return await self._post(
151-
f"/inference-pipelines/{inference_pipeline_id}/data-stream",
153+
path_template(
154+
"/inference-pipelines/{inference_pipeline_id}/data-stream", inference_pipeline_id=inference_pipeline_id
155+
),
152156
body=await async_maybe_transform(
153157
{
154158
"config": config,

src/openlayer/resources/inference_pipelines/inference_pipelines.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
inference_pipeline_retrieve_users_params,
3030
)
3131
from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given
32-
from ..._utils import maybe_transform, async_maybe_transform
32+
from ..._utils import path_template, maybe_transform, async_maybe_transform
3333
from ..._compat import cached_property
3434
from ..._resource import SyncAPIResource, AsyncAPIResource
3535
from ..._response import (
@@ -117,7 +117,7 @@ def retrieve(
117117
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
118118
)
119119
return self._get(
120-
f"/inference-pipelines/{inference_pipeline_id}",
120+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
121121
options=make_request_options(
122122
extra_headers=extra_headers,
123123
extra_query=extra_query,
@@ -168,7 +168,7 @@ def update(
168168
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
169169
)
170170
return self._put(
171-
f"/inference-pipelines/{inference_pipeline_id}",
171+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
172172
body=maybe_transform(
173173
{
174174
"description": description,
@@ -212,7 +212,7 @@ def delete(
212212
)
213213
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
214214
return self._delete(
215-
f"/inference-pipelines/{inference_pipeline_id}",
215+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
216216
options=make_request_options(
217217
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
218218
),
@@ -257,7 +257,9 @@ def retrieve_users(
257257
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
258258
)
259259
return self._get(
260-
f"/inference-pipelines/{inference_pipeline_id}/users",
260+
path_template(
261+
"/inference-pipelines/{inference_pipeline_id}/users", inference_pipeline_id=inference_pipeline_id
262+
),
261263
options=make_request_options(
262264
extra_headers=extra_headers,
263265
extra_query=extra_query,
@@ -338,7 +340,7 @@ async def retrieve(
338340
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
339341
)
340342
return await self._get(
341-
f"/inference-pipelines/{inference_pipeline_id}",
343+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
342344
options=make_request_options(
343345
extra_headers=extra_headers,
344346
extra_query=extra_query,
@@ -389,7 +391,7 @@ async def update(
389391
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
390392
)
391393
return await self._put(
392-
f"/inference-pipelines/{inference_pipeline_id}",
394+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
393395
body=await async_maybe_transform(
394396
{
395397
"description": description,
@@ -433,7 +435,7 @@ async def delete(
433435
)
434436
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
435437
return await self._delete(
436-
f"/inference-pipelines/{inference_pipeline_id}",
438+
path_template("/inference-pipelines/{inference_pipeline_id}", inference_pipeline_id=inference_pipeline_id),
437439
options=make_request_options(
438440
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
439441
),
@@ -478,7 +480,9 @@ async def retrieve_users(
478480
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
479481
)
480482
return await self._get(
481-
f"/inference-pipelines/{inference_pipeline_id}/users",
483+
path_template(
484+
"/inference-pipelines/{inference_pipeline_id}/users", inference_pipeline_id=inference_pipeline_id
485+
),
482486
options=make_request_options(
483487
extra_headers=extra_headers,
484488
extra_query=extra_query,

src/openlayer/resources/inference_pipelines/rows.py

Lines changed: 13 additions & 5 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, 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 (
@@ -77,7 +77,9 @@ def update(
7777
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
7878
)
7979
return self._put(
80-
f"/inference-pipelines/{inference_pipeline_id}/rows",
80+
path_template(
81+
"/inference-pipelines/{inference_pipeline_id}/rows", inference_pipeline_id=inference_pipeline_id
82+
),
8183
body=maybe_transform(
8284
{
8385
"row": row,
@@ -142,7 +144,9 @@ def list(
142144
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
143145
)
144146
return self._post(
145-
f"/inference-pipelines/{inference_pipeline_id}/rows",
147+
path_template(
148+
"/inference-pipelines/{inference_pipeline_id}/rows", inference_pipeline_id=inference_pipeline_id
149+
),
146150
body=maybe_transform(
147151
{
148152
"column_filters": column_filters,
@@ -227,7 +231,9 @@ async def update(
227231
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
228232
)
229233
return await self._put(
230-
f"/inference-pipelines/{inference_pipeline_id}/rows",
234+
path_template(
235+
"/inference-pipelines/{inference_pipeline_id}/rows", inference_pipeline_id=inference_pipeline_id
236+
),
231237
body=await async_maybe_transform(
232238
{
233239
"row": row,
@@ -292,7 +298,9 @@ async def list(
292298
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
293299
)
294300
return await self._post(
295-
f"/inference-pipelines/{inference_pipeline_id}/rows",
301+
path_template(
302+
"/inference-pipelines/{inference_pipeline_id}/rows", inference_pipeline_id=inference_pipeline_id
303+
),
296304
body=await async_maybe_transform(
297305
{
298306
"column_filters": column_filters,

src/openlayer/resources/inference_pipelines/test_results.py

Lines changed: 7 additions & 3 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, NotGiven, 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 (
@@ -87,7 +87,9 @@ def list(
8787
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
8888
)
8989
return self._get(
90-
f"/inference-pipelines/{inference_pipeline_id}/results",
90+
path_template(
91+
"/inference-pipelines/{inference_pipeline_id}/results", inference_pipeline_id=inference_pipeline_id
92+
),
9193
options=make_request_options(
9294
extra_headers=extra_headers,
9395
extra_query=extra_query,
@@ -169,7 +171,9 @@ async def list(
169171
f"Expected a non-empty value for `inference_pipeline_id` but received {inference_pipeline_id!r}"
170172
)
171173
return await self._get(
172-
f"/inference-pipelines/{inference_pipeline_id}/results",
174+
path_template(
175+
"/inference-pipelines/{inference_pipeline_id}/results", inference_pipeline_id=inference_pipeline_id
176+
),
173177
options=make_request_options(
174178
extra_headers=extra_headers,
175179
extra_query=extra_query,

0 commit comments

Comments
 (0)