Skip to content

Commit d22736c

Browse files
cursor[bot]cursoragentprincemuichkine
authored
fix: patch path traversal and missing HTTP timeouts (#1)
* fix: URL-encode path parameters to prevent path traversal All service modules used str.replace() to interpolate user-supplied IDs into URL path templates without URL encoding. The requests library normalizes ../ sequences during URL preparation, so a crafted ID like '../../admin/settings' could redirect requests to unintended API endpoints with the merchant's API key attached. Added _safe_path_param() in client_base.py using urllib.parse.quote() with safe='' and updated all 40 path parameter interpolations across 17 service files to use it. Co-authored-by: Babacar Diop <princemuichkine@users.noreply.github.com> * fix: add configurable HTTP request timeout (default 30s) The SDK's _request() method called session.request() without any timeout parameter, causing all HTTP calls to block indefinitely if the API server is slow or unresponsive. This could exhaust worker threads in consuming applications and halt payment processing. Added a configurable 'timeout' constructor parameter (default 30s) that is passed to every requests.Session.request() call. Co-authored-by: Babacar Diop <princemuichkine@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Babacar Diop <princemuichkine@users.noreply.github.com>
1 parent 3b984f8 commit d22736c

19 files changed

Lines changed: 68 additions & 59 deletions

lomi/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ def _flatten_data(data):
1919
class LomiClient:
2020
"""Merchant API client (public routes only)."""
2121

22+
DEFAULT_TIMEOUT = 30
23+
2224
def __init__(
2325
self,
2426
api_key: str,
2527
base_url: str = "https://api.lomi.africa",
2628
environment: str = "live",
29+
timeout: Optional[int] = None,
2730
):
2831
self.api_key = api_key
32+
self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
2933
test_host = environment in ("test", "sandbox") or (
3034
isinstance(environment, str) and environment.lower() == "test"
3135
)
@@ -70,6 +74,7 @@ def _request(
7074
url=url,
7175
params=params,
7276
json=json_data,
77+
timeout=self.timeout,
7378
)
7479

7580
if response.status_code == 401:

lomi/client_base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11

22
from typing import Optional, Dict, Any, TYPE_CHECKING
3-
import warnings
4-
import requests
3+
from urllib.parse import quote
54

65
from .exceptions import LomiError, LomiAuthError, LomiNotFoundError
76

87
if TYPE_CHECKING:
98
from .client import LomiClient
109

1110

11+
def _safe_path_param(value: Any) -> str:
12+
"""URL-encode a value for safe interpolation into a URL path segment."""
13+
return quote(str(value), safe="")
14+
15+
1216
class ClientBase:
1317
"""HTTP helpers shared by generated services."""
1418

lomi/services/accounts.py

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

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class AccountsService(ClientBase):
@@ -11,7 +11,7 @@ class AccountsService(ClientBase):
1111
def check_balance(self, currency: str) -> Any:
1212
"""Vérifier le solde disponible"""
1313
path = "/accounts/balance/check/{currency}"
14-
path = path.replace("{currency}", str(currency))
14+
path = path.replace("{currency}", _safe_path_param(currency))
1515
return self._request("GET", path)
1616

1717
def get_balance(self, params: Optional[Dict[str, Any]] = None) -> Any:

lomi/services/charges.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class ChargesService(ClientBase):
@@ -11,7 +11,7 @@ class ChargesService(ClientBase):
1111
def cancel_card_charge(self, id: str) -> Any:
1212
"""Annuler un encaissement carte"""
1313
path = "/charge/card/{id}/cancel"
14-
path = path.replace("{id}", str(id))
14+
path = path.replace("{id}", _safe_path_param(id))
1515
return self._request("POST", path)
1616

1717
def create_card_charge(self, body: Optional[Dict[str, Any]] = None) -> Any:
@@ -32,6 +32,6 @@ def create_wave_charge(self) -> Any:
3232
def get_card_charge(self, id: str) -> Any:
3333
"""Obtenir un encaissement carte"""
3434
path = "/charge/card/{id}"
35-
path = path.replace("{id}", str(id))
35+
path = path.replace("{id}", _safe_path_param(id))
3636
return self._request("GET", path)
3737

lomi/services/checkout_sessions.py

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

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class CheckoutSessionsService(ClientBase):
@@ -16,7 +16,7 @@ def create(self, body: Optional[Dict[str, Any]] = None) -> Any:
1616
def get(self, id: str) -> Any:
1717
"""Obtenir une session de paiement par ID"""
1818
path = "/checkout-sessions/{id}"
19-
path = path.replace("{id}", str(id))
19+
path = path.replace("{id}", _safe_path_param(id))
2020
return self._request("GET", path)
2121

2222
def list(self, params: Optional[Dict[str, Any]] = None) -> Any:

lomi/services/customer_subscriptions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class CustomerSubscriptionsService(ClientBase):
@@ -11,13 +11,13 @@ class CustomerSubscriptionsService(ClientBase):
1111
def delete(self, subscription_id: str) -> Any:
1212
"""Cancel customer subscription"""
1313
path = "/customer-subscriptions/{subscription_id}"
14-
path = path.replace("{subscription_id}", str(subscription_id))
14+
path = path.replace("{subscription_id}", _safe_path_param(subscription_id))
1515
return self._request("DELETE", path)
1616

1717
def get(self, subscription_id: str) -> Any:
1818
"""Get customer subscription"""
1919
path = "/customer-subscriptions/{subscription_id}"
20-
path = path.replace("{subscription_id}", str(subscription_id))
20+
path = path.replace("{subscription_id}", _safe_path_param(subscription_id))
2121
return self._request("GET", path)
2222

2323
def list(self, params: Optional[Dict[str, Any]] = None) -> Any:
@@ -28,6 +28,6 @@ def list(self, params: Optional[Dict[str, Any]] = None) -> Any:
2828
def update(self, subscription_id: str) -> Any:
2929
"""Update customer subscription"""
3030
path = "/customer-subscriptions/{subscription_id}"
31-
path = path.replace("{subscription_id}", str(subscription_id))
31+
path = path.replace("{subscription_id}", _safe_path_param(subscription_id))
3232
return self._request("PATCH", path)
3333

lomi/services/customers.py

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

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class CustomersService(ClientBase):
@@ -16,31 +16,31 @@ def create(self, body: Optional[Dict[str, Any]] = None) -> Any:
1616
def create_portal_launch_session(self, id: str, body: Optional[Dict[str, Any]] = None) -> Any:
1717
"""Créer une session de lancement du portail client"""
1818
path = "/customers/{id}/portal-launch-session"
19-
path = path.replace("{id}", str(id))
19+
path = path.replace("{id}", _safe_path_param(id))
2020
return self._request("POST", path, data=body)
2121

2222
def delete(self, id: str) -> Any:
2323
"""Supprimer un client"""
2424
path = "/customers/{id}"
25-
path = path.replace("{id}", str(id))
25+
path = path.replace("{id}", _safe_path_param(id))
2626
return self._request("DELETE", path)
2727

2828
def get(self, id: str) -> Any:
2929
"""Obtenir un client par ID"""
3030
path = "/customers/{id}"
31-
path = path.replace("{id}", str(id))
31+
path = path.replace("{id}", _safe_path_param(id))
3232
return self._request("GET", path)
3333

3434
def get_portal_audit(self, id: str, params: Optional[Dict[str, Any]] = None) -> Any:
3535
"""Hosted customer portal audit"""
3636
path = "/customers/{id}/portal-audit"
37-
path = path.replace("{id}", str(id))
37+
path = path.replace("{id}", _safe_path_param(id))
3838
return self._request("GET", path, params=params)
3939

4040
def get_transactions(self, id: str) -> Any:
4141
"""Transactions du client"""
4242
path = "/customers/{id}/transactions"
43-
path = path.replace("{id}", str(id))
43+
path = path.replace("{id}", _safe_path_param(id))
4444
return self._request("GET", path)
4545

4646
def list(self, params: Optional[Dict[str, Any]] = None) -> Any:
@@ -51,6 +51,6 @@ def list(self, params: Optional[Dict[str, Any]] = None) -> Any:
5151
def update(self, id: str, body: Optional[Dict[str, Any]] = None) -> Any:
5252
"""Mettre à jour un client"""
5353
path = "/customers/{id}"
54-
path = path.replace("{id}", str(id))
54+
path = path.replace("{id}", _safe_path_param(id))
5555
return self._request("PATCH", path, data=body)
5656

lomi/services/discount_coupons.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class DiscountCouponsService(ClientBase):
@@ -16,13 +16,13 @@ def create(self) -> Any:
1616
def get(self, id: str) -> Any:
1717
"""Obtenir un coupon par ID"""
1818
path = "/discount-coupons/{id}"
19-
path = path.replace("{id}", str(id))
19+
path = path.replace("{id}", _safe_path_param(id))
2020
return self._request("GET", path)
2121

2222
def get_performance(self, id: str) -> Any:
2323
"""Indicateurs de performance du coupon"""
2424
path = "/discount-coupons/{id}/performance"
25-
path = path.replace("{id}", str(id))
25+
path = path.replace("{id}", _safe_path_param(id))
2626
return self._request("GET", path)
2727

2828
def list(self) -> Any:

lomi/services/merchants.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class MerchantsService(ClientBase):
@@ -11,24 +11,24 @@ class MerchantsService(ClientBase):
1111
def get(self, id: str) -> Any:
1212
"""Get merchant details"""
1313
path = "/merchants/{id}"
14-
path = path.replace("{id}", str(id))
14+
path = path.replace("{id}", _safe_path_param(id))
1515
return self._request("GET", path)
1616

1717
def get_arr(self, id: str) -> Any:
1818
"""Get merchant ARR"""
1919
path = "/merchants/{id}/arr"
20-
path = path.replace("{id}", str(id))
20+
path = path.replace("{id}", _safe_path_param(id))
2121
return self._request("GET", path)
2222

2323
def get_balance(self, id: str, params: Optional[Dict[str, Any]] = None) -> Any:
2424
"""Get merchant account balance for a currency"""
2525
path = "/merchants/{id}/balance"
26-
path = path.replace("{id}", str(id))
26+
path = path.replace("{id}", _safe_path_param(id))
2727
return self._request("GET", path, params=params)
2828

2929
def get_mrr(self, id: str) -> Any:
3030
"""Get merchant MRR"""
3131
path = "/merchants/{id}/mrr"
32-
path = path.replace("{id}", str(id))
32+
path = path.replace("{id}", _safe_path_param(id))
3333
return self._request("GET", path)
3434

lomi/services/organizations.py

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

33
from typing import Any, Dict, Optional
44

5-
from ..client_base import ClientBase
5+
from ..client_base import ClientBase, _safe_path_param
66

77

88
class OrganizationsService(ClientBase):
@@ -11,7 +11,7 @@ class OrganizationsService(ClientBase):
1111
def get(self, id: str) -> Any:
1212
"""Organisation par ID"""
1313
path = "/organizations/{id}"
14-
path = path.replace("{id}", str(id))
14+
path = path.replace("{id}", _safe_path_param(id))
1515
return self._request("GET", path)
1616

1717
def get_metrics(self) -> Any:

0 commit comments

Comments
 (0)