Skip to content

Commit c4dbd22

Browse files
fix: sanitize endpoint path params
1 parent 4321632 commit c4dbd22

137 files changed

Lines changed: 3563 additions & 1092 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/gcore/_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/gcore/_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/gcore/resources/cdn/audit_logs.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, Omit, Query, Headers, NotGiven, omit, not_given
8-
from ..._utils import maybe_transform
8+
from ..._utils import path_template, maybe_transform
99
from ..._compat import cached_property
1010
from ..._resource import SyncAPIResource, AsyncAPIResource
1111
from ..._response import (
@@ -187,7 +187,7 @@ def get(
187187
timeout: Override the client-level default timeout for this request, in seconds
188188
"""
189189
return self._get(
190-
f"/cdn/activity_log/requests/{log_id}",
190+
path_template("/cdn/activity_log/requests/{log_id}", log_id=log_id),
191191
options=make_request_options(
192192
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
193193
),
@@ -360,7 +360,7 @@ async def get(
360360
timeout: Override the client-level default timeout for this request, in seconds
361361
"""
362362
return await self._get(
363-
f"/cdn/activity_log/requests/{log_id}",
363+
path_template("/cdn/activity_log/requests/{log_id}", log_id=log_id),
364364
options=make_request_options(
365365
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
366366
),

src/gcore/resources/cdn/cdn_resources/cdn_resources.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
AsyncShieldResourceWithStreamingResponse,
2525
)
2626
from ...._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given
27-
from ...._utils import maybe_transform, async_maybe_transform
27+
from ...._utils import path_template, maybe_transform, async_maybe_transform
2828
from ...._compat import cached_property
2929
from ...._resource import SyncAPIResource, AsyncAPIResource
3030
from ...._response import (
@@ -330,7 +330,7 @@ def update(
330330
timeout: Override the client-level default timeout for this request, in seconds
331331
"""
332332
return self._patch(
333-
f"/cdn/resources/{resource_id}",
333+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
334334
body=maybe_transform(
335335
{
336336
"active": active,
@@ -528,7 +528,7 @@ def delete(
528528
"""
529529
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
530530
return self._delete(
531-
f"/cdn/resources/{resource_id}",
531+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
532532
options=make_request_options(
533533
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
534534
),
@@ -559,7 +559,7 @@ def get(
559559
timeout: Override the client-level default timeout for this request, in seconds
560560
"""
561561
return self._get(
562-
f"/cdn/resources/{resource_id}",
562+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
563563
options=make_request_options(
564564
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
565565
),
@@ -606,7 +606,7 @@ def prefetch(
606606
"""
607607
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
608608
return self._post(
609-
f"/cdn/resources/{resource_id}/prefetch",
609+
path_template("/cdn/resources/{resource_id}/prefetch", resource_id=resource_id),
610610
body=maybe_transform({"paths": paths}, cdn_resource_prefetch_params.CDNResourcePrefetchParams),
611611
options=make_request_options(
612612
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -639,7 +639,7 @@ def prevalidate_ssl_le_certificate(
639639
"""
640640
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
641641
return self._post(
642-
f"/cdn/resources/{resource_id}/ssl/le/pre-validate",
642+
path_template("/cdn/resources/{resource_id}/ssl/le/pre-validate", resource_id=resource_id),
643643
options=make_request_options(
644644
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
645645
),
@@ -814,7 +814,7 @@ def purge(
814814
) -> None:
815815
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
816816
return self._post(
817-
f"/cdn/resources/{resource_id}/purge",
817+
path_template("/cdn/resources/{resource_id}/purge", resource_id=resource_id),
818818
body=maybe_transform(
819819
{
820820
"urls": urls,
@@ -933,7 +933,7 @@ def replace(
933933
timeout: Override the client-level default timeout for this request, in seconds
934934
"""
935935
return self._put(
936-
f"/cdn/resources/{resource_id}",
936+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
937937
body=maybe_transform(
938938
{
939939
"origin_group": origin_group,
@@ -1241,7 +1241,7 @@ async def update(
12411241
timeout: Override the client-level default timeout for this request, in seconds
12421242
"""
12431243
return await self._patch(
1244-
f"/cdn/resources/{resource_id}",
1244+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
12451245
body=await async_maybe_transform(
12461246
{
12471247
"active": active,
@@ -1439,7 +1439,7 @@ async def delete(
14391439
"""
14401440
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
14411441
return await self._delete(
1442-
f"/cdn/resources/{resource_id}",
1442+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
14431443
options=make_request_options(
14441444
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
14451445
),
@@ -1470,7 +1470,7 @@ async def get(
14701470
timeout: Override the client-level default timeout for this request, in seconds
14711471
"""
14721472
return await self._get(
1473-
f"/cdn/resources/{resource_id}",
1473+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
14741474
options=make_request_options(
14751475
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
14761476
),
@@ -1517,7 +1517,7 @@ async def prefetch(
15171517
"""
15181518
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
15191519
return await self._post(
1520-
f"/cdn/resources/{resource_id}/prefetch",
1520+
path_template("/cdn/resources/{resource_id}/prefetch", resource_id=resource_id),
15211521
body=await async_maybe_transform({"paths": paths}, cdn_resource_prefetch_params.CDNResourcePrefetchParams),
15221522
options=make_request_options(
15231523
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -1550,7 +1550,7 @@ async def prevalidate_ssl_le_certificate(
15501550
"""
15511551
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
15521552
return await self._post(
1553-
f"/cdn/resources/{resource_id}/ssl/le/pre-validate",
1553+
path_template("/cdn/resources/{resource_id}/ssl/le/pre-validate", resource_id=resource_id),
15541554
options=make_request_options(
15551555
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
15561556
),
@@ -1725,7 +1725,7 @@ async def purge(
17251725
) -> None:
17261726
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
17271727
return await self._post(
1728-
f"/cdn/resources/{resource_id}/purge",
1728+
path_template("/cdn/resources/{resource_id}/purge", resource_id=resource_id),
17291729
body=await async_maybe_transform(
17301730
{
17311731
"urls": urls,
@@ -1844,7 +1844,7 @@ async def replace(
18441844
timeout: Override the client-level default timeout for this request, in seconds
18451845
"""
18461846
return await self._put(
1847-
f"/cdn/resources/{resource_id}",
1847+
path_template("/cdn/resources/{resource_id}", resource_id=resource_id),
18481848
body=await async_maybe_transform(
18491849
{
18501850
"origin_group": origin_group,

0 commit comments

Comments
 (0)