Skip to content

Commit d0928e6

Browse files
fix: reject invalid payment method ids (#142)
* fix: reject invalid payment method ids * chore: add changelog * fix: reject invalid payment method ids --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent ba49e65 commit d0928e6

5 files changed

Lines changed: 73 additions & 7 deletions

File tree

.changelog/vast-mules-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: patch
3+
---
4+
5+
Added strict validation for payment method IDs, requiring them to match `1*LOWERALPHA` (lowercase letters only). Updated `Receipt` default method from empty string to `"tempo"` and fixed test fixtures to use valid method IDs.

src/mpp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ class Receipt:
378378
status: Literal["success"]
379379
timestamp: datetime
380380
reference: str
381-
method: str = ""
381+
method: str = "tempo"
382382
external_id: str | None = None
383383
extra: dict[str, Any] | None = None
384384

src/mpp/_parsing.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
# RFC 9110 auth-param: token BWS "=" BWS ( token / quoted-string )
2828
# Matches: key="value" or key=token, handles escaped quotes in quoted strings
2929
_AUTH_PARAM_RE = re.compile(r'([a-zA-Z_][\w-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))')
30+
# Syntax-level Payment Auth grammar. Supported-method dispatch is handled after parsing.
31+
_PAYMENT_METHOD_ID_RE = re.compile(r"^[a-z]+$")
3032

3133

3234
class ParseError(Exception):
@@ -75,6 +77,12 @@ def _unescape_quoted(s: str) -> str:
7577
return re.sub(r"\\(.)", r"\1", s)
7678

7779

80+
def _validate_payment_method_id(method: str) -> None:
81+
"""Validate payment-method-id = 1*LOWERALPHA."""
82+
if not _PAYMENT_METHOD_ID_RE.fullmatch(method):
83+
raise ParseError(f"Invalid payment method id: {method!r}")
84+
85+
7886
def _parse_auth_params(params_str: str) -> dict[str, str]:
7987
"""Parse RFC 9110 auth-params: key="value" or key=token pairs."""
8088
params: dict[str, str] = {}
@@ -119,6 +127,7 @@ def parse_www_authenticate(header: str) -> Challenge:
119127
method = params.get("method")
120128
if not method:
121129
raise ParseError("Missing 'method' field")
130+
_validate_payment_method_id(method)
122131

123132
intent = params.get("intent")
124133
if not intent:
@@ -214,10 +223,13 @@ def parse_authorization(header: str) -> Credential:
214223
if "id" not in challenge_data:
215224
raise ParseError("Credential challenge missing required field: id")
216225

226+
method = str(challenge_data.get("method", ""))
227+
_validate_payment_method_id(method)
228+
217229
echo = ChallengeEcho(
218230
id=str(challenge_data["id"]),
219231
realm=str(challenge_data.get("realm", "")),
220-
method=str(challenge_data.get("method", "")),
232+
method=method,
221233
intent=str(challenge_data.get("intent", "")),
222234
request=str(challenge_data.get("request", "")),
223235
expires=str(challenge_data["expires"]) if challenge_data.get("expires") else None,
@@ -302,14 +314,16 @@ def parse_payment_receipt(header: str) -> Receipt:
302314
raise ParseError("Invalid receipt status")
303315

304316
timestamp = _parse_timestamp(str(data["timestamp"]))
317+
method = str(data["method"])
318+
_validate_payment_method_id(method)
305319

306320
extra = data.get("extra")
307321

308322
return Receipt(
309323
status=status,
310324
timestamp=timestamp,
311325
reference=str(data["reference"]),
312-
method=str(data.get("method", "")),
326+
method=method,
313327
external_id=str(data["externalId"]) if data.get("externalId") else None,
314328
extra=extra if isinstance(extra, dict) else None,
315329
)

tests/test_parsing.py

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

33
import base64
44
import json
5+
from collections.abc import Mapping
56
from datetime import UTC, datetime
67

78
import pytest
@@ -10,6 +11,12 @@
1011
from mpp._parsing import MAX_HEADER_PAYLOAD_SIZE, ParseError
1112
from tests import make_credential
1213

14+
INVALID_PAYMENT_METHOD_IDS = ("Tempo", "tempo2", "tempo-pay", "tempo_pay", "tempo.pay")
15+
16+
17+
def _b64_json(data: Mapping[str, object]) -> str:
18+
return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=")
19+
1320

1421
class TestChallenge:
1522
def test_roundtrip(self) -> None:
@@ -82,6 +89,16 @@ def test_parse_missing_fields(self) -> None:
8289
with pytest.raises(ParseError, match="Missing 'method' field"):
8390
Challenge.from_www_authenticate(header)
8491

92+
@pytest.mark.parametrize("method", INVALID_PAYMENT_METHOD_IDS)
93+
def test_parse_rejects_invalid_method_id(self, method: str) -> None:
94+
header = (
95+
f'Payment id="test", realm="api.example.com", method="{method}", '
96+
'intent="charge", request="e30"'
97+
)
98+
99+
with pytest.raises(ParseError, match="Invalid payment method id"):
100+
Challenge.from_www_authenticate(header)
101+
85102
def test_roundtrip_with_optional_fields(self) -> None:
86103
"""Challenge with optional fields should survive roundtrip."""
87104
challenge = Challenge(
@@ -238,6 +255,23 @@ def test_parse_challenge_missing_id(self) -> None:
238255
with pytest.raises(ParseError, match="Credential challenge missing required field: id"):
239256
Credential.from_authorization(header)
240257

258+
@pytest.mark.parametrize("method", INVALID_PAYMENT_METHOD_IDS)
259+
def test_parse_rejects_invalid_challenge_method_id(self, method: str) -> None:
260+
data = {
261+
"challenge": {
262+
"id": "test-id",
263+
"realm": "api.example.com",
264+
"method": method,
265+
"intent": "charge",
266+
"request": "e30",
267+
},
268+
"payload": {},
269+
}
270+
header = "Payment " + _b64_json(data)
271+
272+
with pytest.raises(ParseError, match="Invalid payment method id"):
273+
Credential.from_authorization(header)
274+
241275
def test_roundtrip_with_optional_challenge_fields(self) -> None:
242276
credential = Credential(
243277
challenge=ChallengeEcho(
@@ -323,3 +357,16 @@ def test_parse_invalid_timestamp(self) -> None:
323357
b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
324358
with pytest.raises(ParseError, match="Invalid timestamp format"):
325359
Receipt.from_payment_receipt(b64)
360+
361+
@pytest.mark.parametrize("method", INVALID_PAYMENT_METHOD_IDS)
362+
def test_parse_rejects_invalid_method_id(self, method: str) -> None:
363+
payload = {
364+
"status": "success",
365+
"timestamp": "2024-01-20T12:00:00Z",
366+
"reference": "0xabc",
367+
"method": method,
368+
}
369+
b64 = _b64_json(payload)
370+
371+
with pytest.raises(ParseError, match="Invalid payment method id"):
372+
Receipt.from_payment_receipt(b64)

tests/test_server.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async def verify(self, credential: Credential, request: dict) -> Receipt:
186186
request={},
187187
realm="test",
188188
secret_key="test-secret",
189-
method="custom-method",
189+
method="custompay",
190190
intent="custom",
191191
)
192192
auth_header = credential.to_authorization()
@@ -197,7 +197,7 @@ async def verify(self, credential: Credential, request: dict) -> Receipt:
197197
request={},
198198
realm="test",
199199
secret_key="test-secret",
200-
method="custom-method",
200+
method="custompay",
201201
)
202202

203203
assert isinstance(result, tuple)
@@ -779,7 +779,7 @@ async def test_intent(credential: Credential, request: dict) -> Receipt:
779779
request={"amount": "1000"},
780780
realm="api.example.com",
781781
secret_key="test-secret",
782-
method="custom-method",
782+
method="custompay",
783783
)
784784
async def handler(req: MockRequest, credential: Credential, receipt: Receipt) -> dict:
785785
return {"data": "paid"}
@@ -796,7 +796,7 @@ async def handler(req: MockRequest, credential: Credential, receipt: Receipt) ->
796796
assert result["_mpp_challenge"] is True
797797
www_auth = result["headers"]["WWW-Authenticate"]
798798
challenge = Challenge.from_www_authenticate(www_auth)
799-
assert challenge.method == "custom-method"
799+
assert challenge.method == "custompay"
800800

801801

802802
def _make_server(test_intent):

0 commit comments

Comments
 (0)