Repository navigation
Payments security hardening - #706
Conversation
The Strands plugin and LangGraph tools exposed user_id as an LLM-controllable tool argument whose value won over the configured identity. A prompt-injected agent could supply a different user_id to attempt an identity override. The managed service already scopes every read to the server-resolved identity, so no tenant boundary was crossed, but the SDK should not let the model attempt the override in the first place. Add resolve_authoritative_user_id() in config.py and route all 8 tool sites (4 Strands + 4 LangGraph) through it: the configured identity is authoritative, a differing tool-supplied value is ignored and logged. Tool signatures are unchanged (no schema break). Adds regression tests and updates the prior precedence tests to assert the hardened behavior.
…gGraph) The LangGraph middleware's lenient fallback infers an x402 payment challenge from raw tool content that lacks the trusted PAYMENT_REQUIRED: marker, so any tool's output could mint a signed authorization to an actor-chosen recipient and amount (CWE-863: improper authorization on an untrusted channel). The Strands plugin is marker-only and unaffected. Add two defense-in-depth controls on AgentCorePaymentsPluginConfig: - allow_unmarked_402_detection (default True, preserves current behavior): when False, both _fallback_detect_402 call sites are skipped so only the trusted marker path derives a payment. - pre_sign_policy callback at the single signing chokepoint (_generate_payment_header), invoked on every detection path before any value moves; fail-closed on a falsy return or a raise. Adds regression tests plus an end-to-end exploit-reproduction suite that drives the full wrap_tool_call path with an attacker-controlled unmarked payload and asserts no authorization is minted once either control is active.
|
Claude Security Review: no high-confidence findings. (run) |
| pre_sign_policy = self.config.pre_sign_policy | ||
| if pre_sign_policy is not None: | ||
| try: | ||
| allowed = pre_sign_policy(payment_required_request) |
There was a problem hiding this comment.
This fails open for an async def policy. Calling it without await returns a coroutine object, the body never runs, and a coroutine object is truthy. So if not allowed: on line 396 never fires and the payment signs.
Reproduced on this branch with async def deny(req): return False:
config accepted the async policy : True
header returned despite deny : {'X-PAYMENT': 'SIGNED-TOKEN'}
payTo that reached the signer : 0xATTACKER
config.py:211 only checks callable(), which is True for an async def.
The guard already exists in this file. _is_async_callback on line 434 covers the wrappings inspect.iscoroutinefunction misses, and its docstring says outright that callable() cannot distinguish sync from async. Line 575 already hard-raises TypeError for an async on_payment_error.
The async form is also the likely one. A recipient allowlist usually means an HTTP call, and on_payment_error on this same config is documented as async-capable, so whoever writes one async callback writes the second one async too.
Suggest rejecting a non-sync policy in __post_init__. config.py can't import from middleware.py (line 32 imports the other way), so _is_async_callback would need to move to payments/_validation.py, which config.py already imports from.
| allowed = pre_sign_policy(payment_required_request) | ||
| except Exception as exc: # fail-closed: a policy error blocks signing | ||
| raise PaymentError(f"pre_sign_policy rejected the payment (raised {type(exc).__name__}).") from exc | ||
| if not allowed: |
There was a problem hiding this comment.
if not allowed: approves any truthy return. A policy returning {"allowed": False, "reason": "payTo not in allowlist"} signs the payment, because a non-empty dict is truthy. Same for returning the string "deny".
Only False, None, 0, "", {} and [] block. config.py:211 validates callable() and can't check the return type.
if allowed is not True: closes this. Forgetting to return at all still blocks, since None is falsy.
| buyer_pays_gas_fees: Optional[bool] = None | ||
| permit2_allowance_limit: Optional[str] = None | ||
| allow_unmarked_402_detection: bool = True | ||
| pre_sign_policy: Optional[Callable[[Any], bool]] = None |
There was a problem hiding this comment.
This lands on the shared config. Line 50 says it's used by both the Strands plugin and the LangGraph middleware, and __post_init__ validates it for both. But grep -rn pre_sign_policy src/ only hits config.py and langgraph/middleware.py. Strands signs in _process_payment_required_request at strands/plugin.py:490, which never reads the policy.
So a Strands integrator sets a recipient allowlist, gets no error and no warning, and gets no enforcement. Verified: a deny-all policy on a Strands plugin still returns {'X-PAYMENT': 'signed'}.
The docstring compounds it. allow_unmarked_402_detection is marked "(LangGraph only)" on line 102. This one says "the single signing chokepoint ... every detection path" with no scope qualifier, so the docs tell the customer it covers both paths.
Two options. Call the same gate from strands/plugin.py:466, which is about 8 lines lifted verbatim. Or raise in __post_init__ when the policy is set on a config headed for the Strands plugin. Accepting it silently is the one option I'd rule out, since a control that reports success and does nothing is worse on a money path than no control.
| True and enforce ``pre_sign_policy``. | ||
| pre_sign_policy: Optional callback invoked at the single signing chokepoint on every | ||
| detection path (marked, unmarked, and error-retry) before any payment is signed. | ||
| Receives the payment-required request (carrying ``payTo`` and amount) and returns |
There was a problem hiding this comment.
This promise doesn't hold on the error-retry path, and the gap turns into a bypass of the integrator's own gate.
middleware.py:606 and 744 call the gate as self._generate_payment_header(payment_required_request or {}). Those two lines are pre-existing and untouched by this PR, so I'm flagging the interaction rather than a line you changed: the new policy is what now receives that {}.
payment_required_request is None there when _extract_payment_request raised. That's reachable, since custom_handlers is a validated feature (line 183) and a handler whose extract_headers throws gets you to it.
An allowlist written the obvious way, all(a.get("payTo") == TRUSTED for a in accepts), resolves accepts to [] against {}. all([]) is True, so the policy allows and the payment signs. Verified on this branch.
Suggest dropping or {} at both 606 and 744 and raising when payment_required_request is None. Worth tightening this docstring at the same time, since payTo actually lives at body["accepts"][i]["payTo"] for x402 v1 dict bodies, inside a JSON string for v1 string bodies, base64 in headers["Payment-Required"] for v2, and base64url in the WWW-Authenticate challenge for MPP. The one example in the test suite handles the first of those four, so anyone copying it ships a policy that fail-closes every v2 and MPP payment.
| _rh = GenericPaymentHandler() | ||
| retry_status = _rh.extract_status_code(retry_prepared) | ||
| if retry_status != 402: | ||
| if retry_status != 402 and self.config.allow_unmarked_402_detection: |
There was a problem hiding this comment.
This gate is inside _check_retry_rejection, which runs after the payment is signed and sent. The content it inspects comes from the merchant you just paid, not an untrusted channel, so gating it doesn't close any attack surface.
What it costs: with the flag off, an unmarked post-payment rejection isn't recognized, _check_retry_rejection returns None, and wrap_tool_call returns the still-402 body to the model as a successful tool result. PaymentError("Payment was signed but rejected by the server") never fires. Money moved, the merchant refused, and the agent is told it worked.
Suggest dropping and self.config.allow_unmarked_402_detection here and keeping the flag on the two detection sites only. This is the two-minute one.
Address review findings on the pre-sign payment controls:
- Reject an async pre_sign_policy at config construction. An async policy
cannot be awaited at the sync signing chokepoint, so the unawaited coroutine
is truthy and the payment would sign (fail-open).
- Require the policy to return True exactly. A truthy non-bool return (a reason
dict, a status string, a missing return) previously approved signing; now any
value other than True blocks.
- Enforce the policy on the Strands signing path too. It lives on the shared
config and was documented as covering both integrations, but only LangGraph
read it, so a Strands allowlist was silently a no-op.
- Fail closed on an unresolved (None/empty) payment-required request. The
error-retry path passed {} when extraction raised; an allowlist over an empty
accepts list (all([]) is True) then allowed signing.
- Stop gating the post-payment rejection check behind allow_unmarked_402_detection.
It runs on the merchant's own response after value moved; gating it let an
unmarked rejection be reported to the model as success.
Shared is_async_callback and apply_pre_sign_policy helpers moved to
payments/_validation.py so both integrations enforce identical semantics.
Adds regression tests for each finding; documents where payTo lives across
x402 v1/v2 and MPP so a copied allowlist does not fail-close v2/MPP payments.
|
Claude Security Review: no high-confidence findings. (run) |
Issue #, if available: N/A
Description of changes:
This PR adds two defense-in-depth hardening controls to the AgentCore Payments
integrations (Strands plugin and LangGraph middleware). Both are backward
compatible: no tool schema changes, and the content-inferred 402 default is
preserved so existing integrations behave exactly as before unless they opt in.
1. Make the configured
user_idauthoritative in agent toolsThe Strands plugin and LangGraph tools exposed
user_idas an LLM-controllabletool argument whose value won over the configured identity. A prompt-injected
agent could supply a different
user_idto attempt an identity override. Themanaged service already scopes every read to the server-resolved identity, so
no tenant boundary was crossed — but the SDK should not let the model attempt
the override in the first place.
resolve_authoritative_user_id()helper inpayments/integrations/config.py. When an identity is configured, it isauthoritative and a differing tool-supplied
user_idis ignored and logged.When no identity is configured (bearer/JWT auth, where the server resolves the
user from the token), the requested value passes through unchanged.
2. Gate content-inferred 402 detection and add a pre-sign policy (LangGraph)
The LangGraph middleware's lenient fallback infers an x402 payment challenge
from raw tool content that lacks the trusted
PAYMENT_REQUIRED:marker, so anytool's output could cause a signed authorization to be minted for a recipient
and amount taken from untrusted content (CWE-863, improper authorization on an
untrusted channel). The Strands plugin is marker-only and is unaffected.
Two new, opt-in controls on
AgentCorePaymentsPluginConfig:allow_unmarked_402_detection: bool = True(default preserves currentbehavior). When
False, both_fallback_detect_402call sites are skipped,so a payment is derived only from the integration's own payment-bearing tools
via the trusted marker.
pre_sign_policy: Optional[Callable[[Any], bool]] = None. A callback invokedat the single signing chokepoint (
_generate_payment_header) on everydetection path (marked, unmarked, and error-retry) before any value moves. It
receives the payment-required request (carrying
payToand amount) andreturns
Trueto allow orFalseto block. It is fail-closed: a falsy returnor a raised exception blocks signing. Integrators can use it to enforce a
recipient/amount allowlist regardless of the detection default.
Tests
pre-sign policy (allow/block/fail-closed).
wrap_tool_callpath with an attacker-controlled, unmarked payload naming anattacker-chosen
payTo/amount. It asserts (a) the permissive default wouldsign to the attacker (pinning the attack surface) and (b) no authorization is
minted once either control is active, while a legitimate allowlisted recipient
still signs.
user_idoverride-wins precedence areupdated to assert the hardened behavior.
Full payments test suite: 940 passed, 9 skipped.
ruff checkclean.By submitting this pull request, I confirm that you can use, modify, copy, and
redistribute this contribution, under the terms of your choice.