Skip to content

Commit 9445eac

Browse files
fix: sanitize endpoint path params
1 parent acd0c54 commit 9445eac

39 files changed

Lines changed: 577 additions & 253 deletions

src/openai/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ._logs import SensitiveHeadersFilter as SensitiveHeadersFilter
2+
from ._path import path_template as path_template
23
from ._sync import asyncify as asyncify
34
from ._proxy import LazyProxy as LazyProxy
45
from ._utils import (

src/openai/_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/openai/resources/batches.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .. import _legacy_response
1111
from ..types import batch_list_params, batch_create_params
1212
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
13-
from .._utils import maybe_transform, async_maybe_transform
13+
from .._utils import path_template, maybe_transform, async_maybe_transform
1414
from .._compat import cached_property
1515
from .._resource import SyncAPIResource, AsyncAPIResource
1616
from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -154,7 +154,7 @@ def retrieve(
154154
if not batch_id:
155155
raise ValueError(f"Expected a non-empty value for `batch_id` but received {batch_id!r}")
156156
return self._get(
157-
f"/batches/{batch_id}",
157+
path_template("/batches/{batch_id}", batch_id=batch_id),
158158
options=make_request_options(
159159
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
160160
),
@@ -242,7 +242,7 @@ def cancel(
242242
if not batch_id:
243243
raise ValueError(f"Expected a non-empty value for `batch_id` but received {batch_id!r}")
244244
return self._post(
245-
f"/batches/{batch_id}/cancel",
245+
path_template("/batches/{batch_id}/cancel", batch_id=batch_id),
246246
options=make_request_options(
247247
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
248248
),
@@ -382,7 +382,7 @@ async def retrieve(
382382
if not batch_id:
383383
raise ValueError(f"Expected a non-empty value for `batch_id` but received {batch_id!r}")
384384
return await self._get(
385-
f"/batches/{batch_id}",
385+
path_template("/batches/{batch_id}", batch_id=batch_id),
386386
options=make_request_options(
387387
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
388388
),
@@ -470,7 +470,7 @@ async def cancel(
470470
if not batch_id:
471471
raise ValueError(f"Expected a non-empty value for `batch_id` but received {batch_id!r}")
472472
return await self._post(
473-
f"/batches/{batch_id}/cancel",
473+
path_template("/batches/{batch_id}/cancel", batch_id=batch_id),
474474
options=make_request_options(
475475
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
476476
),

src/openai/resources/beta/assistants.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ... import _legacy_response
1212
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
13-
from ..._utils import maybe_transform, async_maybe_transform
13+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1414
from ..._compat import cached_property
1515
from ..._resource import SyncAPIResource, AsyncAPIResource
1616
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -215,7 +215,7 @@ def retrieve(
215215
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
216216
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
217217
return self._get(
218-
f"/assistants/{assistant_id}",
218+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
219219
options=make_request_options(
220220
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
221221
),
@@ -383,7 +383,7 @@ def update(
383383
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
384384
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
385385
return self._post(
386-
f"/assistants/{assistant_id}",
386+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
387387
body=maybe_transform(
388388
{
389389
"description": description,
@@ -500,7 +500,7 @@ def delete(
500500
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
501501
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
502502
return self._delete(
503-
f"/assistants/{assistant_id}",
503+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
504504
options=make_request_options(
505505
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
506506
),
@@ -691,7 +691,7 @@ async def retrieve(
691691
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
692692
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
693693
return await self._get(
694-
f"/assistants/{assistant_id}",
694+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
695695
options=make_request_options(
696696
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
697697
),
@@ -859,7 +859,7 @@ async def update(
859859
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
860860
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
861861
return await self._post(
862-
f"/assistants/{assistant_id}",
862+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
863863
body=await async_maybe_transform(
864864
{
865865
"description": description,
@@ -976,7 +976,7 @@ async def delete(
976976
raise ValueError(f"Expected a non-empty value for `assistant_id` but received {assistant_id!r}")
977977
extra_headers = {"OpenAI-Beta": "assistants=v2", **(extra_headers or {})}
978978
return await self._delete(
979-
f"/assistants/{assistant_id}",
979+
path_template("/assistants/{assistant_id}", assistant_id=assistant_id),
980980
options=make_request_options(
981981
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
982982
),

src/openai/resources/beta/chatkit/sessions.py

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

77
from .... import _legacy_response
88
from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
9-
from ...._utils import maybe_transform, async_maybe_transform
9+
from ...._utils import path_template, maybe_transform, async_maybe_transform
1010
from ...._compat import cached_property
1111
from ...._resource import SyncAPIResource, AsyncAPIResource
1212
from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -134,7 +134,7 @@ def cancel(
134134
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
135135
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
136136
return self._post(
137-
f"/chatkit/sessions/{session_id}/cancel",
137+
path_template("/chatkit/sessions/{session_id}/cancel", session_id=session_id),
138138
options=make_request_options(
139139
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
140140
),
@@ -249,7 +249,7 @@ async def cancel(
249249
raise ValueError(f"Expected a non-empty value for `session_id` but received {session_id!r}")
250250
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
251251
return await self._post(
252-
f"/chatkit/sessions/{session_id}/cancel",
252+
path_template("/chatkit/sessions/{session_id}/cancel", session_id=session_id),
253253
options=make_request_options(
254254
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
255255
),

src/openai/resources/beta/chatkit/threads.py

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

1010
from .... import _legacy_response
1111
from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
12-
from ...._utils import maybe_transform
12+
from ...._utils import path_template, maybe_transform
1313
from ...._compat import cached_property
1414
from ...._resource import SyncAPIResource, AsyncAPIResource
1515
from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -70,7 +70,7 @@ def retrieve(
7070
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
7171
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
7272
return self._get(
73-
f"/chatkit/threads/{thread_id}",
73+
path_template("/chatkit/threads/{thread_id}", thread_id=thread_id),
7474
options=make_request_options(
7575
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7676
),
@@ -167,7 +167,7 @@ def delete(
167167
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
168168
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
169169
return self._delete(
170-
f"/chatkit/threads/{thread_id}",
170+
path_template("/chatkit/threads/{thread_id}", thread_id=thread_id),
171171
options=make_request_options(
172172
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
173173
),
@@ -215,7 +215,7 @@ def list_items(
215215
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
216216
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
217217
return self._get_api_list(
218-
f"/chatkit/threads/{thread_id}/items",
218+
path_template("/chatkit/threads/{thread_id}/items", thread_id=thread_id),
219219
page=SyncConversationCursorPage[Data],
220220
options=make_request_options(
221221
extra_headers=extra_headers,
@@ -283,7 +283,7 @@ async def retrieve(
283283
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
284284
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
285285
return await self._get(
286-
f"/chatkit/threads/{thread_id}",
286+
path_template("/chatkit/threads/{thread_id}", thread_id=thread_id),
287287
options=make_request_options(
288288
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
289289
),
@@ -380,7 +380,7 @@ async def delete(
380380
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
381381
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
382382
return await self._delete(
383-
f"/chatkit/threads/{thread_id}",
383+
path_template("/chatkit/threads/{thread_id}", thread_id=thread_id),
384384
options=make_request_options(
385385
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
386386
),
@@ -428,7 +428,7 @@ def list_items(
428428
raise ValueError(f"Expected a non-empty value for `thread_id` but received {thread_id!r}")
429429
extra_headers = {"OpenAI-Beta": "chatkit_beta=v1", **(extra_headers or {})}
430430
return self._get_api_list(
431-
f"/chatkit/threads/{thread_id}/items",
431+
path_template("/chatkit/threads/{thread_id}/items", thread_id=thread_id),
432432
page=AsyncConversationCursorPage[Data],
433433
options=make_request_options(
434434
extra_headers=extra_headers,

0 commit comments

Comments
 (0)