Skip to content

Commit e87c2a0

Browse files
fix: sanitize endpoint path params
1 parent cff7016 commit e87c2a0

9 files changed

Lines changed: 257 additions & 40 deletions

File tree

src/writerai/_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/writerai/_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/writerai/resources/applications/applications.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from ...types import application_list_params, application_generate_content_params
2727
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
28-
from ..._utils import required_args, maybe_transform, async_maybe_transform
28+
from ..._utils import path_template, required_args, maybe_transform, async_maybe_transform
2929
from ..._compat import cached_property
3030
from ..._resource import SyncAPIResource, AsyncAPIResource
3131
from ..._response import (
@@ -100,7 +100,7 @@ def retrieve(
100100
if not application_id:
101101
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
102102
return self._get(
103-
f"/v1/applications/{application_id}",
103+
path_template("/v1/applications/{application_id}", application_id=application_id),
104104
options=make_request_options(
105105
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
106106
),
@@ -280,7 +280,7 @@ def generate_content(
280280
if not application_id:
281281
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
282282
return self._post(
283-
f"/v1/applications/{application_id}",
283+
path_template("/v1/applications/{application_id}", application_id=application_id),
284284
body=maybe_transform(
285285
{
286286
"inputs": inputs,
@@ -354,7 +354,7 @@ async def retrieve(
354354
if not application_id:
355355
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
356356
return await self._get(
357-
f"/v1/applications/{application_id}",
357+
path_template("/v1/applications/{application_id}", application_id=application_id),
358358
options=make_request_options(
359359
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
360360
),
@@ -534,7 +534,7 @@ async def generate_content(
534534
if not application_id:
535535
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
536536
return await self._post(
537-
f"/v1/applications/{application_id}",
537+
path_template("/v1/applications/{application_id}", application_id=application_id),
538538
body=await async_maybe_transform(
539539
{
540540
"inputs": inputs,

src/writerai/resources/applications/graphs.py

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

77
from ..._types import Body, Query, Headers, NotGiven, SequenceNotStr, 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 (
@@ -72,7 +72,7 @@ def update(
7272
if not application_id:
7373
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
7474
return self._put(
75-
f"/v1/applications/{application_id}/graphs",
75+
path_template("/v1/applications/{application_id}/graphs", application_id=application_id),
7676
body=maybe_transform({"graph_ids": graph_ids}, graph_update_params.GraphUpdateParams),
7777
options=make_request_options(
7878
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -107,7 +107,7 @@ def list(
107107
if not application_id:
108108
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
109109
return self._get(
110-
f"/v1/applications/{application_id}/graphs",
110+
path_template("/v1/applications/{application_id}/graphs", application_id=application_id),
111111
options=make_request_options(
112112
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
113113
),
@@ -166,7 +166,7 @@ async def update(
166166
if not application_id:
167167
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
168168
return await self._put(
169-
f"/v1/applications/{application_id}/graphs",
169+
path_template("/v1/applications/{application_id}/graphs", application_id=application_id),
170170
body=await async_maybe_transform({"graph_ids": graph_ids}, graph_update_params.GraphUpdateParams),
171171
options=make_request_options(
172172
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -201,7 +201,7 @@ async def list(
201201
if not application_id:
202202
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
203203
return await self._get(
204-
f"/v1/applications/{application_id}/graphs",
204+
path_template("/v1/applications/{application_id}/graphs", application_id=application_id),
205205
options=make_request_options(
206206
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
207207
),

src/writerai/resources/applications/jobs.py

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

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 (
@@ -77,7 +77,7 @@ def create(
7777
if not application_id:
7878
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
7979
return self._post(
80-
f"/v1/applications/{application_id}/jobs",
80+
path_template("/v1/applications/{application_id}/jobs", application_id=application_id),
8181
body=maybe_transform({"inputs": inputs}, job_create_params.JobCreateParams),
8282
options=make_request_options(
8383
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -111,7 +111,7 @@ def retrieve(
111111
if not job_id:
112112
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
113113
return self._get(
114-
f"/v1/applications/jobs/{job_id}",
114+
path_template("/v1/applications/jobs/{job_id}", job_id=job_id),
115115
options=make_request_options(
116116
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
117117
),
@@ -154,7 +154,7 @@ def list(
154154
if not application_id:
155155
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
156156
return self._get_api_list(
157-
f"/v1/applications/{application_id}/jobs",
157+
path_template("/v1/applications/{application_id}/jobs", application_id=application_id),
158158
page=SyncApplicationJobsOffset[ApplicationGenerateAsyncResponse],
159159
options=make_request_options(
160160
extra_headers=extra_headers,
@@ -200,7 +200,7 @@ def retry(
200200
if not job_id:
201201
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
202202
return self._post(
203-
f"/v1/applications/jobs/{job_id}/retry",
203+
path_template("/v1/applications/jobs/{job_id}/retry", job_id=job_id),
204204
options=make_request_options(
205205
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
206206
),
@@ -258,7 +258,7 @@ async def create(
258258
if not application_id:
259259
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
260260
return await self._post(
261-
f"/v1/applications/{application_id}/jobs",
261+
path_template("/v1/applications/{application_id}/jobs", application_id=application_id),
262262
body=await async_maybe_transform({"inputs": inputs}, job_create_params.JobCreateParams),
263263
options=make_request_options(
264264
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -292,7 +292,7 @@ async def retrieve(
292292
if not job_id:
293293
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
294294
return await self._get(
295-
f"/v1/applications/jobs/{job_id}",
295+
path_template("/v1/applications/jobs/{job_id}", job_id=job_id),
296296
options=make_request_options(
297297
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
298298
),
@@ -335,7 +335,7 @@ def list(
335335
if not application_id:
336336
raise ValueError(f"Expected a non-empty value for `application_id` but received {application_id!r}")
337337
return self._get_api_list(
338-
f"/v1/applications/{application_id}/jobs",
338+
path_template("/v1/applications/{application_id}/jobs", application_id=application_id),
339339
page=AsyncApplicationJobsOffset[ApplicationGenerateAsyncResponse],
340340
options=make_request_options(
341341
extra_headers=extra_headers,
@@ -381,7 +381,7 @@ async def retry(
381381
if not job_id:
382382
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
383383
return await self._post(
384-
f"/v1/applications/jobs/{job_id}/retry",
384+
path_template("/v1/applications/jobs/{job_id}/retry", job_id=job_id),
385385
options=make_request_options(
386386
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
387387
),

src/writerai/resources/files.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ..types import file_list_params, file_retry_params, file_upload_params
1010
from .._files import _transform_file, get_file_content, _async_transform_file
1111
from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, SequenceNotStr, 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 (
@@ -82,7 +82,7 @@ def retrieve(
8282
if not file_id:
8383
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
8484
return self._get(
85-
f"/v1/files/{file_id}",
85+
path_template("/v1/files/{file_id}", file_id=file_id),
8686
options=make_request_options(
8787
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8888
),
@@ -190,7 +190,7 @@ def delete(
190190
if not file_id:
191191
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
192192
return self._delete(
193-
f"/v1/files/{file_id}",
193+
path_template("/v1/files/{file_id}", file_id=file_id),
194194
options=make_request_options(
195195
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
196196
),
@@ -226,7 +226,7 @@ def download(
226226
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
227227
extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
228228
return self._get(
229-
f"/v1/files/{file_id}/download",
229+
path_template("/v1/files/{file_id}/download", file_id=file_id),
230230
options=make_request_options(
231231
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
232232
),
@@ -370,7 +370,7 @@ async def retrieve(
370370
if not file_id:
371371
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
372372
return await self._get(
373-
f"/v1/files/{file_id}",
373+
path_template("/v1/files/{file_id}", file_id=file_id),
374374
options=make_request_options(
375375
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
376376
),
@@ -478,7 +478,7 @@ async def delete(
478478
if not file_id:
479479
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
480480
return await self._delete(
481-
f"/v1/files/{file_id}",
481+
path_template("/v1/files/{file_id}", file_id=file_id),
482482
options=make_request_options(
483483
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
484484
),
@@ -514,7 +514,7 @@ async def download(
514514
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
515515
extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
516516
return await self._get(
517-
f"/v1/files/{file_id}/download",
517+
path_template("/v1/files/{file_id}/download", file_id=file_id),
518518
options=make_request_options(
519519
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
520520
),

0 commit comments

Comments
 (0)