Skip to content

Commit e6472be

Browse files
fix: sanitize endpoint path params
1 parent 2e74af0 commit e6472be

File tree

10 files changed

+268
-51
lines changed

10 files changed

+268
-51
lines changed

src/agentex/_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/agentex/_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/agentex/resources/agents.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from ..types import agent_rpc_params, agent_list_params, agent_rpc_by_name_params
1313
from .._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, omit, not_given
14-
from .._utils import maybe_transform, async_maybe_transform
14+
from .._utils import path_template, maybe_transform, async_maybe_transform
1515
from .._compat import cached_property
1616
from .._resource import SyncAPIResource, AsyncAPIResource
1717
from .._response import (
@@ -82,7 +82,7 @@ def retrieve(
8282
if not agent_id:
8383
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
8484
return self._get(
85-
f"/agents/{agent_id}",
85+
path_template("/agents/{agent_id}", agent_id=agent_id),
8686
options=make_request_options(
8787
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8888
),
@@ -173,7 +173,7 @@ def delete(
173173
if not agent_id:
174174
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
175175
return self._delete(
176-
f"/agents/{agent_id}",
176+
path_template("/agents/{agent_id}", agent_id=agent_id),
177177
options=make_request_options(
178178
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
179179
),
@@ -206,7 +206,7 @@ def delete_by_name(
206206
if not agent_name:
207207
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
208208
return self._delete(
209-
f"/agents/name/{agent_name}",
209+
path_template("/agents/name/{agent_name}", agent_name=agent_name),
210210
options=make_request_options(
211211
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
212212
),
@@ -239,7 +239,7 @@ def retrieve_by_name(
239239
if not agent_name:
240240
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
241241
return self._get(
242-
f"/agents/name/{agent_name}",
242+
path_template("/agents/name/{agent_name}", agent_name=agent_name),
243243
options=make_request_options(
244244
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
245245
),
@@ -278,7 +278,7 @@ def rpc(
278278
if not agent_id:
279279
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
280280
return self._post(
281-
f"/agents/{agent_id}/rpc",
281+
path_template("/agents/{agent_id}/rpc", agent_id=agent_id),
282282
body=maybe_transform(
283283
{
284284
"method": method,
@@ -326,7 +326,7 @@ def rpc_by_name(
326326
if not agent_name:
327327
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
328328
return self._post(
329-
f"/agents/name/{agent_name}/rpc",
329+
path_template("/agents/name/{agent_name}/rpc", agent_name=agent_name),
330330
body=maybe_transform(
331331
{
332332
"method": method,
@@ -650,7 +650,7 @@ async def retrieve(
650650
if not agent_id:
651651
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
652652
return await self._get(
653-
f"/agents/{agent_id}",
653+
path_template("/agents/{agent_id}", agent_id=agent_id),
654654
options=make_request_options(
655655
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
656656
),
@@ -741,7 +741,7 @@ async def delete(
741741
if not agent_id:
742742
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
743743
return await self._delete(
744-
f"/agents/{agent_id}",
744+
path_template("/agents/{agent_id}", agent_id=agent_id),
745745
options=make_request_options(
746746
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
747747
),
@@ -774,7 +774,7 @@ async def delete_by_name(
774774
if not agent_name:
775775
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
776776
return await self._delete(
777-
f"/agents/name/{agent_name}",
777+
path_template("/agents/name/{agent_name}", agent_name=agent_name),
778778
options=make_request_options(
779779
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
780780
),
@@ -807,7 +807,7 @@ async def retrieve_by_name(
807807
if not agent_name:
808808
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
809809
return await self._get(
810-
f"/agents/name/{agent_name}",
810+
path_template("/agents/name/{agent_name}", agent_name=agent_name),
811811
options=make_request_options(
812812
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
813813
),
@@ -846,7 +846,7 @@ async def rpc(
846846
if not agent_id:
847847
raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}")
848848
return await self._post(
849-
f"/agents/{agent_id}/rpc",
849+
path_template("/agents/{agent_id}/rpc", agent_id=agent_id),
850850
body=await async_maybe_transform(
851851
{
852852
"method": method,
@@ -894,7 +894,7 @@ async def rpc_by_name(
894894
if not agent_name:
895895
raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}")
896896
return await self._post(
897-
f"/agents/name/{agent_name}/rpc",
897+
path_template("/agents/name/{agent_name}/rpc", agent_name=agent_name),
898898
body=await async_maybe_transform(
899899
{
900900
"method": method,

src/agentex/resources/events.py

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

99
from ..types import event_list_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 (
@@ -70,7 +70,7 @@ def retrieve(
7070
if not event_id:
7171
raise ValueError(f"Expected a non-empty value for `event_id` but received {event_id!r}")
7272
return self._get(
73-
f"/events/{event_id}",
73+
path_template("/events/{event_id}", event_id=event_id),
7474
options=make_request_options(
7575
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7676
),
@@ -181,7 +181,7 @@ async def retrieve(
181181
if not event_id:
182182
raise ValueError(f"Expected a non-empty value for `event_id` but received {event_id!r}")
183183
return await self._get(
184-
f"/events/{event_id}",
184+
path_template("/events/{event_id}", event_id=event_id),
185185
options=make_request_options(
186186
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
187187
),

src/agentex/resources/messages/messages.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
message_list_paginated_params,
2323
)
2424
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
25-
from ..._utils import maybe_transform, async_maybe_transform
25+
from ..._utils import path_template, maybe_transform, async_maybe_transform
2626
from ..._compat import cached_property
2727
from ..._resource import SyncAPIResource, AsyncAPIResource
2828
from ..._response import (
@@ -131,7 +131,7 @@ def retrieve(
131131
if not message_id:
132132
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
133133
return self._get(
134-
f"/messages/{message_id}",
134+
path_template("/messages/{message_id}", message_id=message_id),
135135
options=make_request_options(
136136
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
137137
),
@@ -167,7 +167,7 @@ def update(
167167
if not message_id:
168168
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
169169
return self._put(
170-
f"/messages/{message_id}",
170+
path_template("/messages/{message_id}", message_id=message_id),
171171
body=maybe_transform(
172172
{
173173
"content": content,
@@ -1395,7 +1395,7 @@ async def retrieve(
13951395
if not message_id:
13961396
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
13971397
return await self._get(
1398-
f"/messages/{message_id}",
1398+
path_template("/messages/{message_id}", message_id=message_id),
13991399
options=make_request_options(
14001400
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
14011401
),
@@ -1431,7 +1431,7 @@ async def update(
14311431
if not message_id:
14321432
raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}")
14331433
return await self._put(
1434-
f"/messages/{message_id}",
1434+
path_template("/messages/{message_id}", message_id=message_id),
14351435
body=await async_maybe_transform(
14361436
{
14371437
"content": content,

src/agentex/resources/spans.py

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

1010
from ..types import span_list_params, span_create_params, span_update_params
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 (
@@ -142,7 +142,7 @@ def retrieve(
142142
if not span_id:
143143
raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}")
144144
return self._get(
145-
f"/spans/{span_id}",
145+
path_template("/spans/{span_id}", span_id=span_id),
146146
options=make_request_options(
147147
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
148148
),
@@ -199,7 +199,7 @@ def update(
199199
if not span_id:
200200
raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}")
201201
return self._patch(
202-
f"/spans/{span_id}",
202+
path_template("/spans/{span_id}", span_id=span_id),
203203
body=maybe_transform(
204204
{
205205
"data": data,
@@ -385,7 +385,7 @@ async def retrieve(
385385
if not span_id:
386386
raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}")
387387
return await self._get(
388-
f"/spans/{span_id}",
388+
path_template("/spans/{span_id}", span_id=span_id),
389389
options=make_request_options(
390390
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
391391
),
@@ -442,7 +442,7 @@ async def update(
442442
if not span_id:
443443
raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}")
444444
return await self._patch(
445-
f"/spans/{span_id}",
445+
path_template("/spans/{span_id}", span_id=span_id),
446446
body=await async_maybe_transform(
447447
{
448448
"data": data,

0 commit comments

Comments
 (0)