Skip to content

Commit 44c0c8b

Browse files
fix: sanitize endpoint path params
1 parent 73115dc commit 44c0c8b

11 files changed

Lines changed: 283 additions & 54 deletions

File tree

src/neptune_api_v2/_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/neptune_api_v2/_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/neptune_api_v2/resources/user/market/borrow/borrow.py

Lines changed: 11 additions & 11 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 .subaccount import (
1010
SubaccountResource,
1111
AsyncSubaccountResource,
@@ -102,7 +102,7 @@ def get_collateral_accounts_by_asset(
102102
if not address:
103103
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
104104
return self._get(
105-
f"/api/v1/users/{address}/markets/borrow/lookup/collateral",
105+
path_template("/api/v1/users/{address}/markets/borrow/lookup/collateral", address=address),
106106
options=make_request_options(
107107
extra_headers=extra_headers,
108108
extra_query=extra_query,
@@ -154,7 +154,7 @@ def get_collateral_totals(
154154
if not address:
155155
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
156156
return self._get(
157-
f"/api/v1/users/{address}/markets/borrow/sum/collaterals",
157+
path_template("/api/v1/users/{address}/markets/borrow/sum/collaterals", address=address),
158158
options=make_request_options(
159159
extra_headers=extra_headers,
160160
extra_query=extra_query,
@@ -208,7 +208,7 @@ def get_debt_accounts_by_asset(
208208
if not address:
209209
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
210210
return self._get(
211-
f"/api/v1/users/{address}/markets/borrow/lookup/debt",
211+
path_template("/api/v1/users/{address}/markets/borrow/lookup/debt", address=address),
212212
options=make_request_options(
213213
extra_headers=extra_headers,
214214
extra_query=extra_query,
@@ -260,7 +260,7 @@ def get_debts_totals(
260260
if not address:
261261
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
262262
return self._get(
263-
f"/api/v1/users/{address}/markets/borrow/sum/debts",
263+
path_template("/api/v1/users/{address}/markets/borrow/sum/debts", address=address),
264264
options=make_request_options(
265265
extra_headers=extra_headers,
266266
extra_query=extra_query,
@@ -311,7 +311,7 @@ def get_portfolio(
311311
if not address:
312312
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
313313
return self._get(
314-
f"/api/v1/users/{address}/markets/borrow",
314+
path_template("/api/v1/users/{address}/markets/borrow", address=address),
315315
options=make_request_options(
316316
extra_headers=extra_headers,
317317
extra_query=extra_query,
@@ -390,7 +390,7 @@ async def get_collateral_accounts_by_asset(
390390
if not address:
391391
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
392392
return await self._get(
393-
f"/api/v1/users/{address}/markets/borrow/lookup/collateral",
393+
path_template("/api/v1/users/{address}/markets/borrow/lookup/collateral", address=address),
394394
options=make_request_options(
395395
extra_headers=extra_headers,
396396
extra_query=extra_query,
@@ -442,7 +442,7 @@ async def get_collateral_totals(
442442
if not address:
443443
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
444444
return await self._get(
445-
f"/api/v1/users/{address}/markets/borrow/sum/collaterals",
445+
path_template("/api/v1/users/{address}/markets/borrow/sum/collaterals", address=address),
446446
options=make_request_options(
447447
extra_headers=extra_headers,
448448
extra_query=extra_query,
@@ -496,7 +496,7 @@ async def get_debt_accounts_by_asset(
496496
if not address:
497497
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
498498
return await self._get(
499-
f"/api/v1/users/{address}/markets/borrow/lookup/debt",
499+
path_template("/api/v1/users/{address}/markets/borrow/lookup/debt", address=address),
500500
options=make_request_options(
501501
extra_headers=extra_headers,
502502
extra_query=extra_query,
@@ -548,7 +548,7 @@ async def get_debts_totals(
548548
if not address:
549549
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
550550
return await self._get(
551-
f"/api/v1/users/{address}/markets/borrow/sum/debts",
551+
path_template("/api/v1/users/{address}/markets/borrow/sum/debts", address=address),
552552
options=make_request_options(
553553
extra_headers=extra_headers,
554554
extra_query=extra_query,
@@ -599,7 +599,7 @@ async def get_portfolio(
599599
if not address:
600600
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
601601
return await self._get(
602-
f"/api/v1/users/{address}/markets/borrow",
602+
path_template("/api/v1/users/{address}/markets/borrow", address=address),
603603
options=make_request_options(
604604
extra_headers=extra_headers,
605605
extra_query=extra_query,

src/neptune_api_v2/resources/user/market/borrow/subaccount.py

Lines changed: 21 additions & 9 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 (
@@ -90,7 +90,7 @@ def get_subaccount(
9090
if not address:
9191
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
9292
return self._get(
93-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}",
93+
path_template("/api/v1/users/{address}/markets/borrow/accounts/{index}", address=address, index=index),
9494
options=make_request_options(
9595
extra_headers=extra_headers,
9696
extra_query=extra_query,
@@ -144,7 +144,9 @@ def get_subaccount_collaterals(
144144
if not address:
145145
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
146146
return self._get(
147-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/collaterals",
147+
path_template(
148+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/collaterals", address=address, index=index
149+
),
148150
options=make_request_options(
149151
extra_headers=extra_headers,
150152
extra_query=extra_query,
@@ -198,7 +200,9 @@ def get_subaccount_debts(
198200
if not address:
199201
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
200202
return self._get(
201-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/debts",
203+
path_template(
204+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/debts", address=address, index=index
205+
),
202206
options=make_request_options(
203207
extra_headers=extra_headers,
204208
extra_query=extra_query,
@@ -249,7 +253,9 @@ def get_subaccount_health(
249253
if not address:
250254
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
251255
return self._get(
252-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/health",
256+
path_template(
257+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/health", address=address, index=index
258+
),
253259
options=make_request_options(
254260
extra_headers=extra_headers,
255261
extra_query=extra_query,
@@ -321,7 +327,7 @@ async def get_subaccount(
321327
if not address:
322328
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
323329
return await self._get(
324-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}",
330+
path_template("/api/v1/users/{address}/markets/borrow/accounts/{index}", address=address, index=index),
325331
options=make_request_options(
326332
extra_headers=extra_headers,
327333
extra_query=extra_query,
@@ -375,7 +381,9 @@ async def get_subaccount_collaterals(
375381
if not address:
376382
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
377383
return await self._get(
378-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/collaterals",
384+
path_template(
385+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/collaterals", address=address, index=index
386+
),
379387
options=make_request_options(
380388
extra_headers=extra_headers,
381389
extra_query=extra_query,
@@ -429,7 +437,9 @@ async def get_subaccount_debts(
429437
if not address:
430438
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
431439
return await self._get(
432-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/debts",
440+
path_template(
441+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/debts", address=address, index=index
442+
),
433443
options=make_request_options(
434444
extra_headers=extra_headers,
435445
extra_query=extra_query,
@@ -480,7 +490,9 @@ async def get_subaccount_health(
480490
if not address:
481491
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
482492
return await self._get(
483-
f"/api/v1/users/{address}/markets/borrow/accounts/{index}/health",
493+
path_template(
494+
"/api/v1/users/{address}/markets/borrow/accounts/{index}/health", address=address, index=index
495+
),
484496
options=make_request_options(
485497
extra_headers=extra_headers,
486498
extra_query=extra_query,

src/neptune_api_v2/resources/user/market/lend.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, 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 (
@@ -76,7 +76,7 @@ def list(
7676
if not address:
7777
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
7878
return self._get(
79-
f"/api/v1/users/{address}/markets/lend",
79+
path_template("/api/v1/users/{address}/markets/lend", address=address),
8080
options=make_request_options(
8181
extra_headers=extra_headers,
8282
extra_query=extra_query,
@@ -130,7 +130,7 @@ def get_by_asset(
130130
if not address:
131131
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
132132
return self._get(
133-
f"/api/v1/users/{address}/markets/lend/lookup",
133+
path_template("/api/v1/users/{address}/markets/lend/lookup", address=address),
134134
options=make_request_options(
135135
extra_headers=extra_headers,
136136
extra_query=extra_query,
@@ -203,7 +203,7 @@ async def list(
203203
if not address:
204204
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
205205
return await self._get(
206-
f"/api/v1/users/{address}/markets/lend",
206+
path_template("/api/v1/users/{address}/markets/lend", address=address),
207207
options=make_request_options(
208208
extra_headers=extra_headers,
209209
extra_query=extra_query,
@@ -257,7 +257,7 @@ async def get_by_asset(
257257
if not address:
258258
raise ValueError(f"Expected a non-empty value for `address` but received {address!r}")
259259
return await self._get(
260-
f"/api/v1/users/{address}/markets/lend/lookup",
260+
path_template("/api/v1/users/{address}/markets/lend/lookup", address=address),
261261
options=make_request_options(
262262
extra_headers=extra_headers,
263263
extra_query=extra_query,

0 commit comments

Comments
 (0)