dtwo Policy Store

Scrub Unapproved Stripe Payment-Link Redirects

Scrubs the post-payment redirect from Stripe payment-link creation calls.

Direction
ingress
Rego package
stripe.ingress.guard_share_links_payment_redirect
App
stripe
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:0df7ad01ffa897fded9286af4728915b8c638d499ab8f0378b7319cac2fae8e1

stripeguard-share-linksingresstransformphishingprompt-injectionsoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: allow (transform-only — never denies) Package: stripe.ingress.guard_share_links_payment_redirect

What it does

Scrubs the post-payment redirect from Stripe payment-link creation calls. On *create_payment_link tool calls it reads the redirect_url argument and, when it is non-empty and its host is not on a configured domain allowlist, emits a transform that removes the key — the payment link is created without a redirect and Stripe shows its default hosted confirmation page instead.

A payment link is a public, customer-facing URL. Its redirect is where the paying customer's browser is sent the moment payment completes — a prompt-injected agent that sets an attacker-controlled redirect_url turns every link it mints into a phishing/exfiltration vector aimed at your customers, on a page that carries your Stripe branding. Stripe also appends the checkout session id to the redirect, handing the attacker a session reference. Stripping the argument neutralizes the vector while letting the legitimate work (creating the link) proceed.

The allowlist (allowed_redirect_hosts) ships empty on purpose: pin it to your own domains at import time (exact lowercase host match, e.g. "checkout.example.com"). With the empty default, every redirect_url is stripped. The policy runs at ingress, before the public link is minted, so an unapproved redirect never exists on a live URL.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on transmission and movement of information by preventing an agent from wiring a customer-facing payment flow to an unapproved external destination on the MCP path.
  • SOC 2 P6.1 — supports limits on disclosure of personal information to third parties: an attacker-controlled redirect would land paying customers (and the appended checkout-session reference) on a third-party host positioned to harvest their data.
  • GDPR Art. 5(1)(f) / Art. 32 — supports security of processing for the customer payment journey: the post-payment step cannot be diverted to infrastructure outside the controller's approved domains.
  • GDPR Art. 25 — supports data protection by design and by default: the allowlist is empty by default, so the safe behavior (no redirect) is the default behavior until the tenant explicitly pins its own domains.

Tool name matching

Matches by suffix on lower(input.resource.name), case-insensitively, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. stripe-mcp-create_payment_link) and that prefix is not standardized:

  • *create_payment_link — the legacy per-resource tool set (@stripe/mcp v0.8.x, the Claude Desktop .dxt manifest, and pre-migration @stripe/agent-toolkit embeddings), verified from the stripe/ai repo history.

The current official meta-tool server (mcp.stripe.com / @stripe/mcp ≥ 0.9) has no dedicated payment-link tool — payment links are created through the generic stripe_api_write escape hatch, which this policy does not parse. See Composition and Known limitations. Composio and other aggregator tool names for payment links are unverified — confirm the exact name your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

Legacy create_payment_link takes (verified from source):

{ "price": "price_...", "quantity": 1, "redirect_url": "https://..." }

The policy reads redirect_url via object.get(input.payload.args, "redirect_url", "") and never touches price/quantity. Host extraction drops the scheme, cuts the authority at the first /, \, ?, or #, drops userinfo (user@, keeping the host after the **last** @) and the port, and lowercases. Backslash is treated as a path delimiter because WHATWG-conformant browsers normalize \ to / in http/https authorities — otherwise https://evil.example\@allowed/ would parse here as the allowlisted allowed while the browser navigated to evil.example. Values with no :// scheme, non-string values, and anything else that fails to parse are treated as unapproved (fail closed) and stripped. The tool-name match reads both resource.name and its legacy payload.name alias via object.get, so a call missing one of them cannot slip the scrub.

Examples

Transformed — redirect host not on the allowlist

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "stripe-mcp-create_payment_link", "type": "tool" },
    "payload": {
      "name": "stripe-mcp-create_payment_link",
      "args": {
        "price": "price_1QxAbC",
        "quantity": 1,
        "redirect_url": "https://stripe-thanks.attacker.example/collect"
      }
    }
  }
}

allow = true, transform rewrites args to { "price": "price_1QxAbC", "quantity": 1 } — the link is created with no redirect.

Allowed unchanged — no redirect requested

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "stripe-mcp-create_payment_link", "type": "tool" },
    "payload": {
      "name": "stripe-mcp-create_payment_link",
      "args": { "price": "price_1QxAbC", "quantity": 2 }
    }
  }
}

allow = true, no transform.

Composition

Single-purpose by design. Pair with:

Known limitations

  • Allowlist ships empty — pin it at import time. Until you add your own domains to allowed_redirect_hosts, every redirect_url is stripped, including legitimate ones. The allowed-host branch is therefore not exercisable against the shipped constant; verify it after pinning.
  • Exact host match. Subdomains are not implied — list www.example.com and example.com separately. IPv6-literal hosts cannot be allowlisted (their bracket syntax never survives host extraction) and are always stripped.
  • Host extraction only bites once an allowlist is pinned. With the shipped empty allowlist every redirect is stripped regardless of how its host parses, so the host-parsing edge cases (userinfo @, backslash authority terminators, ports) matter only after you pin allowed_redirect_hosts. The extractor models the common browser normalizations (\/, last-@ host, port drop) but is not a full WHATWG URL parser: other exotic normalizations (embedded tab/newline stripping, percent-encoded delimiters, trailing-dot FQDNs) are not modeled. Every one of them extracts a host that will simply fail the exact match and be stripped (fail closed) — the reverse (a stripped-worthy URL surviving) is what the backslash handling closes. Re-verify against your pinned hosts after import.
  • Escape hatch not covered. stripe_api_write on the current official server can create payment links with a redirect in nested after_completion parameters; this policy matches only the dedicated *create_payment_link tools. Compose with stripe/deny-escape-hatches-api-write.
  • Silent scrub. Transform-only policies do not return a reason; the agent learns the redirect was dropped only if it re-reads the created link. If you prefer a hard failure the agent can react to, convert the transform condition into a deny.
  • Aggregator tool names unverified. Composio's STRIPE_* slugs and other community servers may expose payment-link creation under names that do not end in create_payment_link; verify with the dump-input technique.
  • No identity exemptions. All callers are subject to the same scrub. If a trusted group should be allowed arbitrary redirects, add a separate input.subject.claims.groups-gated branch.

Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.

Policy source (Rego)

package stripe.ingress.guard_share_links_payment_redirect

# Transform-only policy — never denies, only scrubs unapproved redirect URLs.
default allow := true

# Hosts allowed to receive the post-payment redirect. Ships EMPTY on purpose:
# pin it to your own domains at import time (exact lowercase host match, e.g.
# "checkout.example.com"; list subdomains individually). With the empty
# default every redirect_url is stripped and the payment link falls back to
# Stripe's default hosted confirmation page.
allowed_redirect_hosts := []

# Payment-link creation tools, matched case-insensitively by suffix because
# the gateway prefixes tool names with the configured MCP server name
# (e.g. `stripe-mcp-create_payment_link`). Covers the legacy per-resource
# Stripe tool set; the current meta-tool server routes payment-link creation
# through `stripe_api_write` (see policy description — compose with
# stripe/deny-escape-hatches-api-write).
#
# Read the tool name via object.get from BOTH the PARC `resource.name` and its
# legacy `payload.name` alias (both populated on tool hooks, same value). A
# bare `input.resource.name` index is undefined when `resource`/`name` is
# absent, which would make this rule — and therefore the scrub transform —
# silently no-op and pass an attacker redirect straight through (fail open).
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

tool_name_alias := lower(object.get(object.get(input, "payload", {}), "name", ""))

is_create_payment_link if endswith(tool_name, "create_payment_link")

is_create_payment_link if endswith(tool_name_alias, "create_payment_link")

args := object.get(object.get(input, "payload", {}), "args", {})

redirect_url := object.get(args, "redirect_url", "")

# Extract the host from the redirect URL: drop the scheme, cut the authority
# at the first `/`, `?`, or `#`, drop any userinfo (`user@`), drop the port,
# and lowercase. Undefined for scheme-less or non-string values — those are
# treated as unapproved (fail closed) and stripped.
redirect_host := host if {
	parts := split(lower(trim_space(redirect_url)), "://")
	count(parts) >= 2
	# Cut the authority at the first path delimiter. Browsers following the
	# WHATWG URL spec treat a backslash in a special-scheme (http/https) URL
	# exactly like a forward slash, so `\` ALSO terminates the authority —
	# split on it too. Without this, `https://evil.example\@allowed.example/`
	# is read here as host `allowed.example` (the last `@`-segment) while the
	# browser navigates to `evil.example`, silently defeating a pinned
	# allowlist.
	authority_and_path := split(split(parts[1], "/")[0], "\\")[0]
	authority_and_query := split(authority_and_path, "?")[0]
	authority := split(authority_and_query, "#")[0]
	# Userinfo tricks like `https://trusted.example@evil.example/` put the real
	# host after the LAST `@`.
	host_candidates := split(authority, "@")
	hostport := host_candidates[count(host_candidates) - 1]
	host := split(hostport, ":")[0]
}

host_is_allowed if {
	some allowed in allowed_redirect_hosts
	lower(allowed) == redirect_host
}

# Strip redirect_url whenever it is present and its host is not approved.
transform := {"transformed_payload": object.remove(args, ["redirect_url"])} if {
	input.action == "tool_pre_invoke"
	is_create_payment_link
	redirect_url != ""
	not host_is_allowed
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)