Skip to content

Payments security hardening - #706

Merged
jariy17 merged 4 commits into
aws:mainfrom
leewc:payments-security-hardening
Oct 22, 2026
Merged

jariy17 merged 4 commits into
aws:mainfrom
leewc:payments-security-hardening

Conversation

@leewc

@leewc leewc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

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_id authoritative in agent tools

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.

  • New resolve_authoritative_user_id() helper in
    payments/integrations/config.py. When an identity is configured, it is
    authoritative and a differing tool-supplied user_id is 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.
  • All 8 tool call sites (4 Strands + 4 LangGraph) route through the helper.
  • Tool signatures are unchanged (no schema break); docstrings updated.

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 any
tool'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 current
    behavior). When False, both _fallback_detect_402 call 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 invoked
    at the single signing chokepoint (_generate_payment_header) on every
    detection path (marked, unmarked, and error-retry) before any value moves. It
    receives the payment-required request (carrying payTo and amount) and
    returns True to allow or False to block. It is fail-closed: a falsy return
    or a raised exception blocks signing. Integrators can use it to enforce a
    recipient/amount allowlist regardless of the detection default.

Tests

  • Unit tests for the helper, config validation, flag-gated detection, and the
    pre-sign policy (allow/block/fail-closed).
  • An end-to-end exploit-reproduction suite that drives the full
    wrap_tool_call path with an attacker-controlled, unmarked payload naming an
    attacker-chosen payTo/amount. It asserts (a) the permissive default would
    sign 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.
  • Prior tests that asserted the old user_id override-wins precedence are
    updated to assert the hardened behavior.

Full payments test suite: 940 passed, 9 skipped. ruff check clean.


By submitting this pull request, I confirm that you can use, modify, copy, and
redistribute this contribution, under the terms of your choice.

leewc added 2 commits October 19, 2026 20:55
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.
@agentcore-devx-automation

Copy link
Copy Markdown
Contributor

Claude Security Review: no high-confidence findings. (run)

@agentcore-devx-automation agentcore-devx-automation Bot removed the claude-security-reviewing Claude Code /security-review in progress label Oct 21, 2026
@leewc
leewc enabled auto-merge (squash) October 21, 2026 18:50
pre_sign_policy = self.config.pre_sign_policy
if pre_sign_policy is not None:
try:
allowed = pre_sign_policy(payment_required_request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@agentcore-devx-automation

Copy link
Copy Markdown
Contributor

Claude Security Review: no high-confidence findings. (run)

@agentcore-devx-automation agentcore-devx-automation Bot removed the claude-security-reviewing Claude Code /security-review in progress label Oct 22, 2026
@jariy17
jariy17 disabled auto-merge October 22, 2026 23:56
@jariy17
jariy17 merged commit 9550e30 into aws:main Oct 22, 2026
28 of 39 checks passed
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active
@leewc
leewc deployed to auto-approve October 23, 2026 00:04 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
auto-approve — df42df59 Deployed Oct 23, 2026 by leewc via Test (payments) #1650
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants