Skip to content

Commit da2a124

Browse files
fix: sanitize endpoint path params
1 parent 21f9e2e commit da2a124

File tree

7 files changed

+239
-21
lines changed

7 files changed

+239
-21
lines changed

src/oz_agent_sdk/_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/oz_agent_sdk/_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/oz_agent_sdk/resources/agent/agent.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from ...types import agent_run_params, agent_list_params
1919
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
20-
from ..._utils import maybe_transform, async_maybe_transform
20+
from ..._utils import path_template, maybe_transform, async_maybe_transform
2121
from .sessions import (
2222
SessionsResource,
2323
AsyncSessionsResource,
@@ -178,7 +178,7 @@ def get_artifact(
178178
if not artifact_uid:
179179
raise ValueError(f"Expected a non-empty value for `artifact_uid` but received {artifact_uid!r}")
180180
return self._get(
181-
f"/agent/artifacts/{artifact_uid}",
181+
path_template("/agent/artifacts/{artifact_uid}", artifact_uid=artifact_uid),
182182
options=make_request_options(
183183
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
184184
),
@@ -392,7 +392,7 @@ async def get_artifact(
392392
if not artifact_uid:
393393
raise ValueError(f"Expected a non-empty value for `artifact_uid` but received {artifact_uid!r}")
394394
return await self._get(
395-
f"/agent/artifacts/{artifact_uid}",
395+
path_template("/agent/artifacts/{artifact_uid}", artifact_uid=artifact_uid),
396396
options=make_request_options(
397397
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
398398
),

src/oz_agent_sdk/resources/agent/runs.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import httpx
1010

1111
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
12-
from ..._utils import maybe_transform, async_maybe_transform
12+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1313
from ..._compat import cached_property
1414
from ..._resource import SyncAPIResource, AsyncAPIResource
1515
from ..._response import (
@@ -77,7 +77,7 @@ def retrieve(
7777
if not run_id:
7878
raise ValueError(f"Expected a non-empty value for `run_id` but received {run_id!r}")
7979
return self._get(
80-
f"/agent/runs/{run_id}",
80+
path_template("/agent/runs/{run_id}", run_id=run_id),
8181
options=make_request_options(
8282
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8383
),
@@ -235,7 +235,7 @@ def cancel(
235235
if not run_id:
236236
raise ValueError(f"Expected a non-empty value for `run_id` but received {run_id!r}")
237237
return self._post(
238-
f"/agent/runs/{run_id}/cancel",
238+
path_template("/agent/runs/{run_id}/cancel", run_id=run_id),
239239
options=make_request_options(
240240
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
241241
),
@@ -292,7 +292,7 @@ async def retrieve(
292292
if not run_id:
293293
raise ValueError(f"Expected a non-empty value for `run_id` but received {run_id!r}")
294294
return await self._get(
295-
f"/agent/runs/{run_id}",
295+
path_template("/agent/runs/{run_id}", run_id=run_id),
296296
options=make_request_options(
297297
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
298298
),
@@ -450,7 +450,7 @@ async def cancel(
450450
if not run_id:
451451
raise ValueError(f"Expected a non-empty value for `run_id` but received {run_id!r}")
452452
return await self._post(
453-
f"/agent/runs/{run_id}/cancel",
453+
path_template("/agent/runs/{run_id}/cancel", run_id=run_id),
454454
options=make_request_options(
455455
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
456456
),

src/oz_agent_sdk/resources/agent/schedules.py

Lines changed: 11 additions & 11 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 (
@@ -137,7 +137,7 @@ def retrieve(
137137
if not schedule_id:
138138
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
139139
return self._get(
140-
f"/agent/schedules/{schedule_id}",
140+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
141141
options=make_request_options(
142142
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
143143
),
@@ -188,7 +188,7 @@ def update(
188188
if not schedule_id:
189189
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
190190
return self._put(
191-
f"/agent/schedules/{schedule_id}",
191+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
192192
body=maybe_transform(
193193
{
194194
"cron_schedule": cron_schedule,
@@ -255,7 +255,7 @@ def delete(
255255
if not schedule_id:
256256
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
257257
return self._delete(
258-
f"/agent/schedules/{schedule_id}",
258+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
259259
options=make_request_options(
260260
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
261261
),
@@ -290,7 +290,7 @@ def pause(
290290
if not schedule_id:
291291
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
292292
return self._post(
293-
f"/agent/schedules/{schedule_id}/pause",
293+
path_template("/agent/schedules/{schedule_id}/pause", schedule_id=schedule_id),
294294
options=make_request_options(
295295
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
296296
),
@@ -325,7 +325,7 @@ def resume(
325325
if not schedule_id:
326326
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
327327
return self._post(
328-
f"/agent/schedules/{schedule_id}/resume",
328+
path_template("/agent/schedules/{schedule_id}/resume", schedule_id=schedule_id),
329329
options=make_request_options(
330330
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
331331
),
@@ -446,7 +446,7 @@ async def retrieve(
446446
if not schedule_id:
447447
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
448448
return await self._get(
449-
f"/agent/schedules/{schedule_id}",
449+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
450450
options=make_request_options(
451451
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
452452
),
@@ -497,7 +497,7 @@ async def update(
497497
if not schedule_id:
498498
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
499499
return await self._put(
500-
f"/agent/schedules/{schedule_id}",
500+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
501501
body=await async_maybe_transform(
502502
{
503503
"cron_schedule": cron_schedule,
@@ -564,7 +564,7 @@ async def delete(
564564
if not schedule_id:
565565
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
566566
return await self._delete(
567-
f"/agent/schedules/{schedule_id}",
567+
path_template("/agent/schedules/{schedule_id}", schedule_id=schedule_id),
568568
options=make_request_options(
569569
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
570570
),
@@ -599,7 +599,7 @@ async def pause(
599599
if not schedule_id:
600600
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
601601
return await self._post(
602-
f"/agent/schedules/{schedule_id}/pause",
602+
path_template("/agent/schedules/{schedule_id}/pause", schedule_id=schedule_id),
603603
options=make_request_options(
604604
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
605605
),
@@ -634,7 +634,7 @@ async def resume(
634634
if not schedule_id:
635635
raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}")
636636
return await self._post(
637-
f"/agent/schedules/{schedule_id}/resume",
637+
path_template("/agent/schedules/{schedule_id}/resume", schedule_id=schedule_id),
638638
options=make_request_options(
639639
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
640640
),

src/oz_agent_sdk/resources/agent/sessions.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 (
@@ -69,7 +70,7 @@ def check_redirect(
6970
if not session_uuid:
7071
raise ValueError(f"Expected a non-empty value for `session_uuid` but received {session_uuid!r}")
7172
return self._get(
72-
f"/agent/sessions/{session_uuid}/redirect",
73+
path_template("/agent/sessions/{session_uuid}/redirect", session_uuid=session_uuid),
7374
options=make_request_options(
7475
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7576
),
@@ -127,7 +128,7 @@ async def check_redirect(
127128
if not session_uuid:
128129
raise ValueError(f"Expected a non-empty value for `session_uuid` but received {session_uuid!r}")
129130
return await self._get(
130-
f"/agent/sessions/{session_uuid}/redirect",
131+
path_template("/agent/sessions/{session_uuid}/redirect", session_uuid=session_uuid),
131132
options=make_request_options(
132133
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
133134
),

0 commit comments

Comments
 (0)