Skip to content

Commit a87e9d4

Browse files
fix: sanitize endpoint path params
1 parent 4d1960a commit a87e9d4

43 files changed

Lines changed: 443 additions & 137 deletions

Some content is hidden

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

src/coingecko_sdk/_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/coingecko_sdk/_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/coingecko_sdk/resources/coins/circulating_supply_chart.py

Lines changed: 5 additions & 5 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 (
@@ -77,7 +77,7 @@ def get(
7777
if not id:
7878
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
7979
return self._get(
80-
f"/coins/{id}/circulating_supply_chart",
80+
path_template("/coins/{id}/circulating_supply_chart", id=id),
8181
options=make_request_options(
8282
extra_headers=extra_headers,
8383
extra_query=extra_query,
@@ -129,7 +129,7 @@ def get_range(
129129
if not id:
130130
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
131131
return self._get(
132-
f"/coins/{id}/circulating_supply_chart/range",
132+
path_template("/coins/{id}/circulating_supply_chart/range", id=id),
133133
options=make_request_options(
134134
extra_headers=extra_headers,
135135
extra_query=extra_query,
@@ -200,7 +200,7 @@ async def get(
200200
if not id:
201201
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
202202
return await self._get(
203-
f"/coins/{id}/circulating_supply_chart",
203+
path_template("/coins/{id}/circulating_supply_chart", id=id),
204204
options=make_request_options(
205205
extra_headers=extra_headers,
206206
extra_query=extra_query,
@@ -252,7 +252,7 @@ async def get_range(
252252
if not id:
253253
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
254254
return await self._get(
255-
f"/coins/{id}/circulating_supply_chart/range",
255+
path_template("/coins/{id}/circulating_supply_chart/range", id=id),
256256
options=make_request_options(
257257
extra_headers=extra_headers,
258258
extra_query=extra_query,

src/coingecko_sdk/resources/coins/coins.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
AsyncTickersResourceWithStreamingResponse,
4949
)
5050
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
51-
from ..._utils import maybe_transform, async_maybe_transform
51+
from ..._utils import path_template, maybe_transform, async_maybe_transform
5252
from ..._compat import cached_property
5353
from .categories import (
5454
CategoriesResource,
@@ -230,7 +230,7 @@ def get_id(
230230
if not id:
231231
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
232232
return self._get(
233-
f"/coins/{id}",
233+
path_template("/coins/{id}", id=id),
234234
options=make_request_options(
235235
extra_headers=extra_headers,
236236
extra_query=extra_query,
@@ -373,7 +373,7 @@ async def get_id(
373373
if not id:
374374
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
375375
return await self._get(
376-
f"/coins/{id}",
376+
path_template("/coins/{id}", id=id),
377377
options=make_request_options(
378378
extra_headers=extra_headers,
379379
extra_query=extra_query,

src/coingecko_sdk/resources/coins/contract/contract.py

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

77
from ...._types import Body, Query, Headers, NotGiven, not_given
8+
from ...._utils import path_template
89
from ...._compat import cached_property
910
from ...._resource import SyncAPIResource, AsyncAPIResource
1011
from ...._response import (
@@ -83,7 +84,7 @@ def get(
8384
if not contract_address:
8485
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
8586
return self._get(
86-
f"/coins/{id}/contract/{contract_address}",
87+
path_template("/coins/{id}/contract/{contract_address}", id=id, contract_address=contract_address),
8788
options=make_request_options(
8889
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
8990
),
@@ -147,7 +148,7 @@ async def get(
147148
if not contract_address:
148149
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
149150
return await self._get(
150-
f"/coins/{id}/contract/{contract_address}",
151+
path_template("/coins/{id}/contract/{contract_address}", id=id, contract_address=contract_address),
151152
options=make_request_options(
152153
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
153154
),

src/coingecko_sdk/resources/coins/contract/market_chart.py

Lines changed: 13 additions & 5 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 (
@@ -111,7 +111,9 @@ def get(
111111
if not contract_address:
112112
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
113113
return self._get(
114-
f"/coins/{id}/contract/{contract_address}/market_chart",
114+
path_template(
115+
"/coins/{id}/contract/{contract_address}/market_chart", id=id, contract_address=contract_address
116+
),
115117
options=make_request_options(
116118
extra_headers=extra_headers,
117119
extra_query=extra_query,
@@ -201,7 +203,9 @@ def get_range(
201203
if not contract_address:
202204
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
203205
return self._get(
204-
f"/coins/{id}/contract/{contract_address}/market_chart/range",
206+
path_template(
207+
"/coins/{id}/contract/{contract_address}/market_chart/range", id=id, contract_address=contract_address
208+
),
205209
options=make_request_options(
206210
extra_headers=extra_headers,
207211
extra_query=extra_query,
@@ -309,7 +313,9 @@ async def get(
309313
if not contract_address:
310314
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
311315
return await self._get(
312-
f"/coins/{id}/contract/{contract_address}/market_chart",
316+
path_template(
317+
"/coins/{id}/contract/{contract_address}/market_chart", id=id, contract_address=contract_address
318+
),
313319
options=make_request_options(
314320
extra_headers=extra_headers,
315321
extra_query=extra_query,
@@ -399,7 +405,9 @@ async def get_range(
399405
if not contract_address:
400406
raise ValueError(f"Expected a non-empty value for `contract_address` but received {contract_address!r}")
401407
return await self._get(
402-
f"/coins/{id}/contract/{contract_address}/market_chart/range",
408+
path_template(
409+
"/coins/{id}/contract/{contract_address}/market_chart/range", id=id, contract_address=contract_address
410+
),
403411
options=make_request_options(
404412
extra_headers=extra_headers,
405413
extra_query=extra_query,

src/coingecko_sdk/resources/coins/history.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, 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 (
@@ -74,7 +74,7 @@ def get(
7474
if not id:
7575
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
7676
return self._get(
77-
f"/coins/{id}/history",
77+
path_template("/coins/{id}/history", id=id),
7878
options=make_request_options(
7979
extra_headers=extra_headers,
8080
extra_query=extra_query,
@@ -145,7 +145,7 @@ async def get(
145145
if not id:
146146
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
147147
return await self._get(
148-
f"/coins/{id}/history",
148+
path_template("/coins/{id}/history", id=id),
149149
options=make_request_options(
150150
extra_headers=extra_headers,
151151
extra_query=extra_query,

src/coingecko_sdk/resources/coins/market_chart.py

Lines changed: 5 additions & 5 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 (
@@ -107,7 +107,7 @@ def get(
107107
if not id:
108108
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
109109
return self._get(
110-
f"/coins/{id}/market_chart",
110+
path_template("/coins/{id}/market_chart", id=id),
111111
options=make_request_options(
112112
extra_headers=extra_headers,
113113
extra_query=extra_query,
@@ -194,7 +194,7 @@ def get_range(
194194
if not id:
195195
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
196196
return self._get(
197-
f"/coins/{id}/market_chart/range",
197+
path_template("/coins/{id}/market_chart/range", id=id),
198198
options=make_request_options(
199199
extra_headers=extra_headers,
200200
extra_query=extra_query,
@@ -298,7 +298,7 @@ async def get(
298298
if not id:
299299
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
300300
return await self._get(
301-
f"/coins/{id}/market_chart",
301+
path_template("/coins/{id}/market_chart", id=id),
302302
options=make_request_options(
303303
extra_headers=extra_headers,
304304
extra_query=extra_query,
@@ -385,7 +385,7 @@ async def get_range(
385385
if not id:
386386
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
387387
return await self._get(
388-
f"/coins/{id}/market_chart/range",
388+
path_template("/coins/{id}/market_chart/range", id=id),
389389
options=make_request_options(
390390
extra_headers=extra_headers,
391391
extra_query=extra_query,

0 commit comments

Comments
 (0)