Skip to content

Commit 82ab871

Browse files
fix: sanitize endpoint path params
1 parent 6c03717 commit 82ab871

10 files changed

Lines changed: 258 additions & 41 deletions

File tree

src/moderation_api/_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/moderation_api/_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/moderation_api/resources/actions/actions.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
AsyncExecuteResourceWithStreamingResponse,
1818
)
1919
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, 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 ..._compat import cached_property
2222
from ..._resource import SyncAPIResource, AsyncAPIResource
2323
from ..._response import (
@@ -189,7 +189,7 @@ def retrieve(
189189
if not id:
190190
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
191191
return self._get(
192-
f"/actions/{id}",
192+
path_template("/actions/{id}", id=id),
193193
options=make_request_options(
194194
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
195195
),
@@ -278,7 +278,7 @@ def update(
278278
if not id:
279279
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
280280
return self._put(
281-
f"/actions/{id}",
281+
path_template("/actions/{id}", id=id),
282282
body=maybe_transform(
283283
{
284284
"built_in": built_in,
@@ -365,7 +365,7 @@ def delete(
365365
if not id:
366366
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
367367
return self._delete(
368-
f"/actions/{id}",
368+
path_template("/actions/{id}", id=id),
369369
options=make_request_options(
370370
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
371371
),
@@ -526,7 +526,7 @@ async def retrieve(
526526
if not id:
527527
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
528528
return await self._get(
529-
f"/actions/{id}",
529+
path_template("/actions/{id}", id=id),
530530
options=make_request_options(
531531
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
532532
),
@@ -615,7 +615,7 @@ async def update(
615615
if not id:
616616
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
617617
return await self._put(
618-
f"/actions/{id}",
618+
path_template("/actions/{id}", id=id),
619619
body=await async_maybe_transform(
620620
{
621621
"built_in": built_in,
@@ -702,7 +702,7 @@ async def delete(
702702
if not id:
703703
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
704704
return await self._delete(
705-
f"/actions/{id}",
705+
path_template("/actions/{id}", id=id),
706706
options=make_request_options(
707707
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
708708
),

src/moderation_api/resources/actions/execute.py

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

99
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
10-
from ..._utils import maybe_transform, async_maybe_transform
10+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1111
from ..._compat import cached_property
1212
from ..._resource import SyncAPIResource, AsyncAPIResource
1313
from ..._response import (
@@ -144,7 +144,7 @@ def execute_by_id(
144144
if not action_id:
145145
raise ValueError(f"Expected a non-empty value for `action_id` but received {action_id!r}")
146146
return self._post(
147-
f"/actions/{action_id}/execute",
147+
path_template("/actions/{action_id}/execute", action_id=action_id),
148148
body=maybe_transform(
149149
{
150150
"author_ids": author_ids,
@@ -281,7 +281,7 @@ async def execute_by_id(
281281
if not action_id:
282282
raise ValueError(f"Expected a non-empty value for `action_id` but received {action_id!r}")
283283
return await self._post(
284-
f"/actions/{action_id}/execute",
284+
path_template("/actions/{action_id}/execute", action_id=action_id),
285285
body=await async_maybe_transform(
286286
{
287287
"author_ids": author_ids,

src/moderation_api/resources/authors.py

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

1010
from ..types import author_list_params, author_create_params, author_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 (
@@ -149,7 +149,7 @@ def retrieve(
149149
if not id:
150150
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
151151
return self._get(
152-
f"/authors/{id}",
152+
path_template("/authors/{id}", id=id),
153153
options=make_request_options(
154154
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
155155
),
@@ -207,7 +207,7 @@ def update(
207207
if not id:
208208
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
209209
return self._put(
210-
f"/authors/{id}",
210+
path_template("/authors/{id}", id=id),
211211
body=maybe_transform(
212212
{
213213
"email": email,
@@ -321,7 +321,7 @@ def delete(
321321
if not id:
322322
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
323323
return self._delete(
324-
f"/authors/{id}",
324+
path_template("/authors/{id}", id=id),
325325
options=make_request_options(
326326
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
327327
),
@@ -450,7 +450,7 @@ async def retrieve(
450450
if not id:
451451
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
452452
return await self._get(
453-
f"/authors/{id}",
453+
path_template("/authors/{id}", id=id),
454454
options=make_request_options(
455455
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
456456
),
@@ -508,7 +508,7 @@ async def update(
508508
if not id:
509509
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
510510
return await self._put(
511-
f"/authors/{id}",
511+
path_template("/authors/{id}", id=id),
512512
body=await async_maybe_transform(
513513
{
514514
"email": email,
@@ -622,7 +622,7 @@ async def delete(
622622
if not id:
623623
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
624624
return await self._delete(
625-
f"/authors/{id}",
625+
path_template("/authors/{id}", id=id),
626626
options=make_request_options(
627627
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
628628
),

src/moderation_api/resources/queue/items.py

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

99
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
10-
from ..._utils import maybe_transform, async_maybe_transform
10+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1111
from ..._compat import cached_property
1212
from ..._resource import SyncAPIResource, AsyncAPIResource
1313
from ..._response import (
@@ -90,7 +90,7 @@ def list(
9090
if not id:
9191
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
9292
return self._get(
93-
f"/queue/{id}/items",
93+
path_template("/queue/{id}/items", id=id),
9494
options=make_request_options(
9595
extra_headers=extra_headers,
9696
extra_query=extra_query,
@@ -152,7 +152,7 @@ def resolve(
152152
if not item_id:
153153
raise ValueError(f"Expected a non-empty value for `item_id` but received {item_id!r}")
154154
return self._post(
155-
f"/queue/{id}/items/{item_id}/resolve",
155+
path_template("/queue/{id}/items/{item_id}/resolve", id=id, item_id=item_id),
156156
body=maybe_transform({"comment": comment}, item_resolve_params.ItemResolveParams),
157157
options=make_request_options(
158158
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -196,7 +196,7 @@ def unresolve(
196196
if not item_id:
197197
raise ValueError(f"Expected a non-empty value for `item_id` but received {item_id!r}")
198198
return self._post(
199-
f"/queue/{id}/items/{item_id}/unresolve",
199+
path_template("/queue/{id}/items/{item_id}/unresolve", id=id, item_id=item_id),
200200
body=maybe_transform({"comment": comment}, item_unresolve_params.ItemUnresolveParams),
201201
options=make_request_options(
202202
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -270,7 +270,7 @@ async def list(
270270
if not id:
271271
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
272272
return await self._get(
273-
f"/queue/{id}/items",
273+
path_template("/queue/{id}/items", id=id),
274274
options=make_request_options(
275275
extra_headers=extra_headers,
276276
extra_query=extra_query,
@@ -332,7 +332,7 @@ async def resolve(
332332
if not item_id:
333333
raise ValueError(f"Expected a non-empty value for `item_id` but received {item_id!r}")
334334
return await self._post(
335-
f"/queue/{id}/items/{item_id}/resolve",
335+
path_template("/queue/{id}/items/{item_id}/resolve", id=id, item_id=item_id),
336336
body=await async_maybe_transform({"comment": comment}, item_resolve_params.ItemResolveParams),
337337
options=make_request_options(
338338
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -376,7 +376,7 @@ async def unresolve(
376376
if not item_id:
377377
raise ValueError(f"Expected a non-empty value for `item_id` but received {item_id!r}")
378378
return await self._post(
379-
f"/queue/{id}/items/{item_id}/unresolve",
379+
path_template("/queue/{id}/items/{item_id}/unresolve", id=id, item_id=item_id),
380380
body=await async_maybe_transform({"comment": comment}, item_unresolve_params.ItemUnresolveParams),
381381
options=make_request_options(
382382
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

src/moderation_api/resources/queue/queue.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from ...types import queue_get_stats_params
1616
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
17-
from ..._utils import maybe_transform, async_maybe_transform
17+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1818
from ..._compat import cached_property
1919
from ..._resource import SyncAPIResource, AsyncAPIResource
2020
from ..._response import (
@@ -82,7 +82,7 @@ def retrieve(
8282
if not id:
8383
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
8484
return self._get(
85-
f"/queue/{id}",
85+
path_template("/queue/{id}", id=id),
8686
options=make_request_options(
8787
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8888
),
@@ -121,7 +121,7 @@ def get_stats(
121121
if not id:
122122
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
123123
return self._get(
124-
f"/queue/{id}/stats",
124+
path_template("/queue/{id}/stats", id=id),
125125
options=make_request_options(
126126
extra_headers=extra_headers,
127127
extra_query=extra_query,
@@ -185,7 +185,7 @@ async def retrieve(
185185
if not id:
186186
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
187187
return await self._get(
188-
f"/queue/{id}",
188+
path_template("/queue/{id}", id=id),
189189
options=make_request_options(
190190
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
191191
),
@@ -224,7 +224,7 @@ async def get_stats(
224224
if not id:
225225
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
226226
return await self._get(
227-
f"/queue/{id}/stats",
227+
path_template("/queue/{id}/stats", id=id),
228228
options=make_request_options(
229229
extra_headers=extra_headers,
230230
extra_query=extra_query,

0 commit comments

Comments
 (0)