Skip to content

Commit 3bdb99d

Browse files
fix: sanitize endpoint path params
1 parent 40b6ef4 commit 3bdb99d

48 files changed

Lines changed: 872 additions & 286 deletions

Some content is hidden

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

src/lithic/_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/lithic/_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/lithic/resources/account_activity.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .. import _legacy_response
1212
from ..types import account_activity_list_params
1313
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
14-
from .._utils import maybe_transform
14+
from .._utils import path_template, maybe_transform
1515
from .._compat import cached_property
1616
from .._resource import SyncAPIResource, AsyncAPIResource
1717
from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -184,7 +184,7 @@ def retrieve_transaction(
184184
return cast(
185185
AccountActivityRetrieveTransactionResponse,
186186
self._get(
187-
f"/v1/account_activity/{transaction_token}",
187+
path_template("/v1/account_activity/{transaction_token}", transaction_token=transaction_token),
188188
options=make_request_options(
189189
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
190190
),
@@ -356,7 +356,7 @@ async def retrieve_transaction(
356356
return cast(
357357
AccountActivityRetrieveTransactionResponse,
358358
await self._get(
359-
f"/v1/account_activity/{transaction_token}",
359+
path_template("/v1/account_activity/{transaction_token}", transaction_token=transaction_token),
360360
options=make_request_options(
361361
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
362362
),

src/lithic/resources/account_holders/account_holders.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
account_holder_simulate_enrollment_document_review_params,
1919
)
2020
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
21-
from ..._utils import is_given, required_args, maybe_transform, async_maybe_transform
21+
from ..._utils import is_given, path_template, required_args, maybe_transform, async_maybe_transform
2222
from .entities import (
2323
Entities,
2424
AsyncEntities,
@@ -461,7 +461,7 @@ def retrieve(
461461
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
462462
)
463463
return self._get(
464-
f"/v1/account_holders/{account_holder_token}",
464+
path_template("/v1/account_holders/{account_holder_token}", account_holder_token=account_holder_token),
465465
options=make_request_options(
466466
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
467467
),
@@ -679,7 +679,7 @@ def update(
679679
return cast(
680680
AccountHolderUpdateResponse,
681681
self._patch(
682-
f"/v1/account_holders/{account_holder_token}",
682+
path_template("/v1/account_holders/{account_holder_token}", account_holder_token=account_holder_token),
683683
body=maybe_transform(
684684
{
685685
"beneficial_owner_individuals": beneficial_owner_individuals,
@@ -843,7 +843,9 @@ def list_documents(
843843
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
844844
)
845845
return self._get(
846-
f"/v1/account_holders/{account_holder_token}/documents",
846+
path_template(
847+
"/v1/account_holders/{account_holder_token}/documents", account_holder_token=account_holder_token
848+
),
847849
options=make_request_options(
848850
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
849851
),
@@ -894,7 +896,11 @@ def retrieve_document(
894896
if not document_token:
895897
raise ValueError(f"Expected a non-empty value for `document_token` but received {document_token!r}")
896898
return self._get(
897-
f"/v1/account_holders/{account_holder_token}/documents/{document_token}",
899+
path_template(
900+
"/v1/account_holders/{account_holder_token}/documents/{document_token}",
901+
account_holder_token=account_holder_token,
902+
document_token=document_token,
903+
),
898904
options=make_request_options(
899905
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
900906
),
@@ -1108,7 +1114,9 @@ def upload_document(
11081114
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
11091115
)
11101116
return self._post(
1111-
f"/v1/account_holders/{account_holder_token}/documents",
1117+
path_template(
1118+
"/v1/account_holders/{account_holder_token}/documents", account_holder_token=account_holder_token
1119+
),
11121120
body=maybe_transform(
11131121
{
11141122
"document_type": document_type,
@@ -1539,7 +1547,7 @@ async def retrieve(
15391547
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
15401548
)
15411549
return await self._get(
1542-
f"/v1/account_holders/{account_holder_token}",
1550+
path_template("/v1/account_holders/{account_holder_token}", account_holder_token=account_holder_token),
15431551
options=make_request_options(
15441552
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
15451553
),
@@ -1757,7 +1765,7 @@ async def update(
17571765
return cast(
17581766
AccountHolderUpdateResponse,
17591767
await self._patch(
1760-
f"/v1/account_holders/{account_holder_token}",
1768+
path_template("/v1/account_holders/{account_holder_token}", account_holder_token=account_holder_token),
17611769
body=await async_maybe_transform(
17621770
{
17631771
"beneficial_owner_individuals": beneficial_owner_individuals,
@@ -1921,7 +1929,9 @@ async def list_documents(
19211929
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
19221930
)
19231931
return await self._get(
1924-
f"/v1/account_holders/{account_holder_token}/documents",
1932+
path_template(
1933+
"/v1/account_holders/{account_holder_token}/documents", account_holder_token=account_holder_token
1934+
),
19251935
options=make_request_options(
19261936
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
19271937
),
@@ -1972,7 +1982,11 @@ async def retrieve_document(
19721982
if not document_token:
19731983
raise ValueError(f"Expected a non-empty value for `document_token` but received {document_token!r}")
19741984
return await self._get(
1975-
f"/v1/account_holders/{account_holder_token}/documents/{document_token}",
1985+
path_template(
1986+
"/v1/account_holders/{account_holder_token}/documents/{document_token}",
1987+
account_holder_token=account_holder_token,
1988+
document_token=document_token,
1989+
),
19761990
options=make_request_options(
19771991
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
19781992
),
@@ -2186,7 +2200,9 @@ async def upload_document(
21862200
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
21872201
)
21882202
return await self._post(
2189-
f"/v1/account_holders/{account_holder_token}/documents",
2203+
path_template(
2204+
"/v1/account_holders/{account_holder_token}/documents", account_holder_token=account_holder_token
2205+
),
21902206
body=await async_maybe_transform(
21912207
{
21922208
"document_type": document_type,

src/lithic/resources/account_holders/entities.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ... import _legacy_response
1010
from ..._types import Body, Query, Headers, NotGiven, not_given
11-
from ..._utils import maybe_transform, async_maybe_transform
11+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -101,7 +101,9 @@ def create(
101101
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
102102
)
103103
return self._post(
104-
f"/v1/account_holders/{account_holder_token}/entities",
104+
path_template(
105+
"/v1/account_holders/{account_holder_token}/entities", account_holder_token=account_holder_token
106+
),
105107
body=maybe_transform(
106108
{
107109
"address": address,
@@ -154,7 +156,11 @@ def delete(
154156
if not entity_token:
155157
raise ValueError(f"Expected a non-empty value for `entity_token` but received {entity_token!r}")
156158
return self._delete(
157-
f"/v1/account_holders/{account_holder_token}/entities/{entity_token}",
159+
path_template(
160+
"/v1/account_holders/{account_holder_token}/entities/{entity_token}",
161+
account_holder_token=account_holder_token,
162+
entity_token=entity_token,
163+
),
158164
options=make_request_options(
159165
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
160166
),
@@ -243,7 +249,9 @@ async def create(
243249
f"Expected a non-empty value for `account_holder_token` but received {account_holder_token!r}"
244250
)
245251
return await self._post(
246-
f"/v1/account_holders/{account_holder_token}/entities",
252+
path_template(
253+
"/v1/account_holders/{account_holder_token}/entities", account_holder_token=account_holder_token
254+
),
247255
body=await async_maybe_transform(
248256
{
249257
"address": address,
@@ -296,7 +304,11 @@ async def delete(
296304
if not entity_token:
297305
raise ValueError(f"Expected a non-empty value for `entity_token` but received {entity_token!r}")
298306
return await self._delete(
299-
f"/v1/account_holders/{account_holder_token}/entities/{entity_token}",
307+
path_template(
308+
"/v1/account_holders/{account_holder_token}/entities/{entity_token}",
309+
account_holder_token=account_holder_token,
310+
entity_token=entity_token,
311+
),
300312
options=make_request_options(
301313
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
302314
),

src/lithic/resources/accounts.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .. import _legacy_response
1212
from ..types import account_list_params, account_update_params
1313
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
14-
from .._utils import maybe_transform, async_maybe_transform
14+
from .._utils import path_template, maybe_transform, async_maybe_transform
1515
from .._compat import cached_property
1616
from .._resource import SyncAPIResource, AsyncAPIResource
1717
from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -69,7 +69,7 @@ def retrieve(
6969
if not account_token:
7070
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
7171
return self._get(
72-
f"/v1/accounts/{account_token}",
72+
path_template("/v1/accounts/{account_token}", account_token=account_token),
7373
options=make_request_options(
7474
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7575
),
@@ -174,7 +174,7 @@ def update(
174174
if not account_token:
175175
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
176176
return self._patch(
177-
f"/v1/accounts/{account_token}",
177+
path_template("/v1/accounts/{account_token}", account_token=account_token),
178178
body=maybe_transform(
179179
{
180180
"comment": comment,
@@ -287,7 +287,7 @@ def retrieve_spend_limits(
287287
if not account_token:
288288
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
289289
return self._get(
290-
f"/v1/accounts/{account_token}/spend_limits",
290+
path_template("/v1/accounts/{account_token}/spend_limits", account_token=account_token),
291291
options=make_request_options(
292292
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
293293
),
@@ -341,7 +341,7 @@ async def retrieve(
341341
if not account_token:
342342
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
343343
return await self._get(
344-
f"/v1/accounts/{account_token}",
344+
path_template("/v1/accounts/{account_token}", account_token=account_token),
345345
options=make_request_options(
346346
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
347347
),
@@ -446,7 +446,7 @@ async def update(
446446
if not account_token:
447447
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
448448
return await self._patch(
449-
f"/v1/accounts/{account_token}",
449+
path_template("/v1/accounts/{account_token}", account_token=account_token),
450450
body=await async_maybe_transform(
451451
{
452452
"comment": comment,
@@ -559,7 +559,7 @@ async def retrieve_spend_limits(
559559
if not account_token:
560560
raise ValueError(f"Expected a non-empty value for `account_token` but received {account_token!r}")
561561
return await self._get(
562-
f"/v1/accounts/{account_token}/spend_limits",
562+
path_template("/v1/accounts/{account_token}/spend_limits", account_token=account_token),
563563
options=make_request_options(
564564
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
565565
),

0 commit comments

Comments
 (0)