Skip to content

Commit 548cb06

Browse files
vvillait88claude
andcommitted
fix(checkout): strip payment headers from ctx.request.raw on malformed re-challenge
Parity with node-commerce 2.7.3. The malformed re-challenge stripped the CheckoutRequest headers but left the native request (ctx.request.raw) carrying the junk credential, so hooks that read raw (e.g. mint_multichain_recipients, which parses the MPP credential off raw's Authorization: Payment header) still saw the junk and raised 401, turning the fresh-402 re-challenge into a dead end. Fix wraps raw in a header-stripping proxy so the re-entry is a discovery leg for every view of the request. Regression test exercises a hook reading ctx.request.raw (the martin-estate shape); non-payment headers still pass through. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f216f84 commit 548cb06

4 files changed

Lines changed: 149 additions & 7 deletions

File tree

agentscore_commerce/checkout.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,76 @@ def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:
709709
return build_identity_metadata(mode="wallet", wallet=wallet, linked_wallets=linked_wallets)
710710

711711

712+
def _header_is_payment_credential(orig: Any, key: str) -> bool:
713+
lk = key.lower()
714+
if lk in ("payment-signature", "x-payment"):
715+
return True
716+
if lk == "authorization":
717+
value = orig.get("authorization")
718+
return isinstance(value, str) and value.startswith("Payment ")
719+
return False
720+
721+
722+
class _StrippedHeaders:
723+
"""Read-only view over a framework ``headers`` mapping that hides credentials.
724+
725+
Hides the payment-credential headers (``x-payment`` / ``payment-signature`` /
726+
an ``Authorization: Payment`` value) while supporting the ``.get`` / ``[]`` /
727+
``in`` / ``.items`` / iteration access patterns hooks use.
728+
"""
729+
730+
def __init__(self, orig: Any) -> None:
731+
self._orig = orig
732+
733+
def get(self, key: str, default: Any = None) -> Any:
734+
if _header_is_payment_credential(self._orig, key):
735+
return default
736+
return self._orig.get(key, default)
737+
738+
def __getitem__(self, key: str) -> Any:
739+
if _header_is_payment_credential(self._orig, key):
740+
raise KeyError(key)
741+
return self._orig[key]
742+
743+
def __contains__(self, key: str) -> bool:
744+
if _header_is_payment_credential(self._orig, key):
745+
return False
746+
return key in self._orig
747+
748+
def items(self) -> Any:
749+
return [(k, v) for k, v in self._orig.items() if not _header_is_payment_credential(self._orig, k)]
750+
751+
def __iter__(self) -> Any:
752+
return iter(k for k in self._orig if not _header_is_payment_credential(self._orig, k))
753+
754+
755+
class _RawHeaderStripProxy:
756+
"""Wrap the native request so ``.headers`` hides payment credentials.
757+
758+
Every other attribute (``.json``, ``.scope``, mppx's fetch surface, ...)
759+
delegates to the original request unchanged.
760+
"""
761+
762+
def __init__(self, raw: Any, headers: _StrippedHeaders) -> None:
763+
object.__setattr__(self, "_raw", raw)
764+
object.__setattr__(self, "headers", headers)
765+
766+
def __getattr__(self, name: str) -> Any:
767+
return getattr(object.__getattribute__(self, "_raw"), name)
768+
769+
770+
def _strip_payment_headers_from_raw(raw: Any) -> Any:
771+
"""Return ``raw`` with the payment-credential headers hidden.
772+
773+
Hooks that read the native request (``ctx.request.raw``) on the malformed
774+
re-challenge then see a discovery leg. Non-header-bearing ``raw`` (or
775+
``None``) passes through unchanged.
776+
"""
777+
if raw is None or not hasattr(raw, "headers"):
778+
return raw
779+
return _RawHeaderStripProxy(raw, _StrippedHeaders(raw.headers))
780+
781+
712782
class Checkout:
713783
"""High-level agent-commerce orchestrator.
714784
@@ -2685,10 +2755,11 @@ def _strip_payment_headers(self, request: CheckoutRequest) -> CheckoutRequest:
26852755
Re-entering handle() with it treats the request as a discovery
26862756
(no-credential) request: pre_validate + pricing + minting + compose run
26872757
their fresh path, and the gate/assess and settle are skipped. Turns a
2688-
malformed-credential request into a clean 402 re-challenge. The raw
2689-
request is left intact; compose_mppx reads it only best-effort under a
2690-
try/except, while the stripped headers are what the shape check, gate
2691-
dispatch, and recipient minting read.
2758+
malformed-credential request into a clean 402 re-challenge. The native
2759+
request (``raw``) is stripped in lockstep with ``headers`` so hooks that
2760+
read ``ctx.request.raw`` (e.g. ``mint_multichain_recipients``, which
2761+
parses the MPP credential off the raw ``Authorization: Payment`` header)
2762+
also see a discovery leg instead of throwing on the junk credential.
26922763
"""
26932764
headers = {
26942765
k: v
@@ -2698,7 +2769,7 @@ def _strip_payment_headers(self, request: CheckoutRequest) -> CheckoutRequest:
26982769
or (k.lower() == "authorization" and v.startswith("Payment "))
26992770
)
27002771
}
2701-
return dataclasses.replace(request, headers=headers)
2772+
return dataclasses.replace(request, headers=headers, raw=_strip_payment_headers_from_raw(request.raw))
27022773

27032774
async def _emit_402(
27042775
self,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "2.5.8"
7+
version = "2.5.9"
88
description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce."
99
readme = "README.md"
1010
license = "MIT"

tests/test_credential_precheck.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,31 @@
1515
MppxComposeOutcome,
1616
PricingResult,
1717
)
18+
from agentscore_commerce.errors import CheckoutValidationError
1819
from agentscore_commerce.payment import TempoRailSpec, X402BaseRailSpec, malformed_payment_credential
1920

21+
22+
class _RawHeaders:
23+
"""A framework-style headers mapping (case-insensitive .get) for a fake raw request."""
24+
25+
def __init__(self, mapping: dict[str, str]) -> None:
26+
self._m = {k.lower(): v for k, v in mapping.items()}
27+
28+
def get(self, key: str, default: Any = None) -> Any:
29+
return self._m.get(key.lower(), default)
30+
31+
def items(self) -> Any:
32+
return list(self._m.items())
33+
34+
def __iter__(self) -> Any:
35+
return iter(self._m)
36+
37+
38+
class _RawReq:
39+
def __init__(self, headers: dict[str, str]) -> None:
40+
self.headers = _RawHeaders(headers)
41+
42+
2043
VALID_MPP = (
2144
"Payment "
2245
+ base64.b64encode(
@@ -73,6 +96,54 @@ async def _compose(_ctx: Any) -> MppxComposeOutcome:
7396
assert "pre_validate" in calls
7497

7598

99+
@pytest.mark.asyncio
100+
async def test_rechallenge_strips_credential_from_raw_request_too() -> None:
101+
# Regression: the re-challenge must be a discovery leg for EVERY view of the
102+
# request, including the native ``ctx.request.raw`` that hooks like
103+
# ``mint_multichain_recipients`` read. A hook parsing the MPP credential off
104+
# ``ctx.request.raw`` and raising on junk (the martin-estate shape) would
105+
# otherwise turn the fresh-402 re-challenge back into a 401 dead end.
106+
raw_auth_seen: dict[str, Any] = {}
107+
108+
async def _mint(ctx: Any) -> dict[str, str]:
109+
raw = ctx.request.raw
110+
auth = raw.headers.get("authorization") if raw is not None else None
111+
raw_auth_seen["value"] = auth
112+
if auth is not None and auth.startswith("Payment "):
113+
raise CheckoutValidationError(
114+
code="invalid_credential",
115+
message="The Authorization: Payment header is not a valid MPP credential.",
116+
action="retry_without_credential",
117+
status=401,
118+
)
119+
return {"tempo": "0xtempo"}
120+
121+
async def _compose(_ctx: Any) -> MppxComposeOutcome:
122+
return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="fresh"'})
123+
124+
checkout = Checkout(
125+
rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dEaD")},
126+
url="https://api.example/purchase",
127+
pre_validate=lambda _ctx: {},
128+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
129+
compose_mppx=_compose,
130+
mint_recipients=_mint,
131+
)
132+
req = CheckoutRequest(
133+
method="POST",
134+
url="https://api.example/purchase",
135+
headers={"authorization": "Payment total-garbage!!!"},
136+
body={"item": "wine"},
137+
raw=_RawReq({"authorization": "Payment total-garbage!!!", "x-wallet-address": "0xabc"}),
138+
)
139+
result = await checkout.handle(req)
140+
assert result.status == 402
141+
assert result.settle_phase == "credential_malformed"
142+
# The hook ran on the re-entry and saw a raw with the credential stripped;
143+
# non-payment headers (x-wallet-address) still pass through.
144+
assert raw_auth_seen["value"] is None
145+
146+
76147
@pytest.mark.asyncio
77148
async def test_junk_x402_header_rechallenges_with_fresh_402_discovery_flow() -> None:
78149
calls: list[str] = []

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)