Skip to content

Commit fa1972c

Browse files
fix: sanitize endpoint path params
1 parent 369ff73 commit fa1972c

File tree

12 files changed

+268
-49
lines changed

12 files changed

+268
-49
lines changed

src/imagekitio/_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/imagekitio/_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/imagekitio/resources/accounts/origins.py

Lines changed: 7 additions & 7 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, NoneType, NotGiven, omit, not_given
11-
from ..._utils import required_args, maybe_transform, async_maybe_transform
11+
from ..._utils import path_template, required_args, maybe_transform, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import (
@@ -948,7 +948,7 @@ def update(
948948
return cast(
949949
OriginResponse,
950950
self._put(
951-
f"/v1/accounts/origins/{id}",
951+
path_template("/v1/accounts/origins/{id}", id=id),
952952
body=maybe_transform(
953953
{
954954
"access_key": access_key,
@@ -1038,7 +1038,7 @@ def delete(
10381038
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
10391039
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
10401040
return self._delete(
1041-
f"/v1/accounts/origins/{id}",
1041+
path_template("/v1/accounts/origins/{id}", id=id),
10421042
options=make_request_options(
10431043
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
10441044
),
@@ -1078,7 +1078,7 @@ def get(
10781078
return cast(
10791079
OriginResponse,
10801080
self._get(
1081-
f"/v1/accounts/origins/{id}",
1081+
path_template("/v1/accounts/origins/{id}", id=id),
10821082
options=make_request_options(
10831083
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
10841084
),
@@ -2010,7 +2010,7 @@ async def update(
20102010
return cast(
20112011
OriginResponse,
20122012
await self._put(
2013-
f"/v1/accounts/origins/{id}",
2013+
path_template("/v1/accounts/origins/{id}", id=id),
20142014
body=await async_maybe_transform(
20152015
{
20162016
"access_key": access_key,
@@ -2100,7 +2100,7 @@ async def delete(
21002100
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
21012101
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
21022102
return await self._delete(
2103-
f"/v1/accounts/origins/{id}",
2103+
path_template("/v1/accounts/origins/{id}", id=id),
21042104
options=make_request_options(
21052105
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
21062106
),
@@ -2140,7 +2140,7 @@ async def get(
21402140
return cast(
21412141
OriginResponse,
21422142
await self._get(
2143-
f"/v1/accounts/origins/{id}",
2143+
path_template("/v1/accounts/origins/{id}", id=id),
21442144
options=make_request_options(
21452145
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
21462146
),

src/imagekitio/resources/accounts/url_endpoints.py

Lines changed: 7 additions & 7 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, NoneType, NotGiven, SequenceNotStr, 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 (
@@ -145,7 +145,7 @@ def update(
145145
if not id:
146146
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
147147
return self._put(
148-
f"/v1/accounts/url-endpoints/{id}",
148+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
149149
body=maybe_transform(
150150
{
151151
"description": description,
@@ -219,7 +219,7 @@ def delete(
219219
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
220220
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
221221
return self._delete(
222-
f"/v1/accounts/url-endpoints/{id}",
222+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
223223
options=make_request_options(
224224
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
225225
),
@@ -258,7 +258,7 @@ def get(
258258
if not id:
259259
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
260260
return self._get(
261-
f"/v1/accounts/url-endpoints/{id}",
261+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
262262
options=make_request_options(
263263
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
264264
),
@@ -389,7 +389,7 @@ async def update(
389389
if not id:
390390
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
391391
return await self._put(
392-
f"/v1/accounts/url-endpoints/{id}",
392+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
393393
body=await async_maybe_transform(
394394
{
395395
"description": description,
@@ -463,7 +463,7 @@ async def delete(
463463
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
464464
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
465465
return await self._delete(
466-
f"/v1/accounts/url-endpoints/{id}",
466+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
467467
options=make_request_options(
468468
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
469469
),
@@ -502,7 +502,7 @@ async def get(
502502
if not id:
503503
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
504504
return await self._get(
505-
f"/v1/accounts/url-endpoints/{id}",
505+
path_template("/v1/accounts/url-endpoints/{id}", id=id),
506506
options=make_request_options(
507507
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
508508
),

src/imagekitio/resources/cache/invalidation.py

Lines changed: 3 additions & 3 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, 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 (
@@ -105,7 +105,7 @@ def get(
105105
if not request_id:
106106
raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
107107
return self._get(
108-
f"/v1/files/purge/{request_id}",
108+
path_template("/v1/files/purge/{request_id}", request_id=request_id),
109109
options=make_request_options(
110110
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
111111
),
@@ -196,7 +196,7 @@ async def get(
196196
if not request_id:
197197
raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
198198
return await self._get(
199-
f"/v1/files/purge/{request_id}",
199+
path_template("/v1/files/purge/{request_id}", request_id=request_id),
200200
options=make_request_options(
201201
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
202202
),

src/imagekitio/resources/custom_metadata_fields.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
custom_metadata_field_update_params,
1111
)
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 (
@@ -138,7 +138,7 @@ def update(
138138
if not id:
139139
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
140140
return self._patch(
141-
f"/v1/customMetadataFields/{id}",
141+
path_template("/v1/customMetadataFields/{id}", id=id),
142142
body=maybe_transform(
143143
{
144144
"label": label,
@@ -237,7 +237,7 @@ def delete(
237237
if not id:
238238
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
239239
return self._delete(
240-
f"/v1/customMetadataFields/{id}",
240+
path_template("/v1/customMetadataFields/{id}", id=id),
241241
options=make_request_options(
242242
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
243243
),
@@ -356,7 +356,7 @@ async def update(
356356
if not id:
357357
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
358358
return await self._patch(
359-
f"/v1/customMetadataFields/{id}",
359+
path_template("/v1/customMetadataFields/{id}", id=id),
360360
body=await async_maybe_transform(
361361
{
362362
"label": label,
@@ -455,7 +455,7 @@ async def delete(
455455
if not id:
456456
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
457457
return await self._delete(
458-
f"/v1/customMetadataFields/{id}",
458+
path_template("/v1/customMetadataFields/{id}", id=id),
459459
options=make_request_options(
460460
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
461461
),

src/imagekitio/resources/files/files.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
omit,
3535
not_given,
3636
)
37-
from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
37+
from ..._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform
3838
from .metadata import (
3939
MetadataResource,
4040
AsyncMetadataResource,
@@ -221,7 +221,7 @@ def update(
221221
if not file_id:
222222
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
223223
return self._patch(
224-
f"/v1/files/{file_id}/details",
224+
path_template("/v1/files/{file_id}/details", file_id=file_id),
225225
body=maybe_transform(
226226
{
227227
"custom_coordinates": custom_coordinates,
@@ -272,7 +272,7 @@ def delete(
272272
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
273273
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
274274
return self._delete(
275-
f"/v1/files/{file_id}",
275+
path_template("/v1/files/{file_id}", file_id=file_id),
276276
options=make_request_options(
277277
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
278278
),
@@ -359,7 +359,7 @@ def get(
359359
if not file_id:
360360
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
361361
return self._get(
362-
f"/v1/files/{file_id}/details",
362+
path_template("/v1/files/{file_id}/details", file_id=file_id),
363363
options=make_request_options(
364364
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
365365
),
@@ -895,7 +895,7 @@ async def update(
895895
if not file_id:
896896
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
897897
return await self._patch(
898-
f"/v1/files/{file_id}/details",
898+
path_template("/v1/files/{file_id}/details", file_id=file_id),
899899
body=await async_maybe_transform(
900900
{
901901
"custom_coordinates": custom_coordinates,
@@ -946,7 +946,7 @@ async def delete(
946946
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
947947
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
948948
return await self._delete(
949-
f"/v1/files/{file_id}",
949+
path_template("/v1/files/{file_id}", file_id=file_id),
950950
options=make_request_options(
951951
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
952952
),
@@ -1033,7 +1033,7 @@ async def get(
10331033
if not file_id:
10341034
raise ValueError(f"Expected a non-empty value for `file_id` but received {file_id!r}")
10351035
return await self._get(
1036-
f"/v1/files/{file_id}/details",
1036+
path_template("/v1/files/{file_id}/details", file_id=file_id),
10371037
options=make_request_options(
10381038
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
10391039
),

0 commit comments

Comments
 (0)