
When Your Payment Page Redirects to an Attacker: The Sylius IDOR You Should Fix Now
The Sylius IDOR GHSA-mr9r-h354-966r lets attackers read payment requests, recover order tokens, and redirect buyers to malicious URLs — all without authentication. Here's the breakdown, the fix, and why API ownership checks can't be an afterthought.
I read the advisory while sipping coffee yesterday morning, and it's one of those vulnerabilities that makes you wince — not because it's technically exotic, but because it's so straightforward and so damaging. Sylius disclosed an IDOR (Insecure Direct Object Reference) across three payment request API endpoints that, until patched in versions 2.0.18, 2.1.15, and 2.2.6, let anyone with a UUID read payment details, uncover full customer orders, and even hijack the post-payment redirect to phish the buyer. If you run a Sylius shop, stop reading and upgrade. Now.
Here's the thing: the vulnerability sits right at the intersection of three critical ecommerce functions — payments, orders, and user identity — and it abuses a single missing ownership check to chain together a full account takeover of the transaction flow. Let's break it down, look at what went wrong, and unpack what the fix (and the provided workaround) actually does under the hood.
The vulnerable endpoints
Two API endpoints are the initial entry points:
GET /api/v2/shop/payment-requests/{hash}PUT /api/v2/shop/payment-requests/{hash}
Both look up the payment request solely by the hash from the URL. No check whether the authenticated customer (or anonymous guest) actually owns the underlying order. If you have the hash, you have full access. The hash is a UUID, so it's not trivially guessable — it has to be obtained out-of-band from logs, shared links, referrer headers, or a co-located attacker. But once you have it, that's it. There's no second factor, no session token, no order ownership validation.
The GET response includes a payment IRI that contains the underlying order's tokenValue. And here's where it gets ugly: in Sylius, the order token grants full read access to the order — items, addresses, customer email, totals, everything. So an attacker who snags a payment request hash can walk it right back to the full order, including the buyer's personal data.
The PUT endpoint is even worse. The payment request has fields like target_path and after_path that the front-end controller uses to redirect the customer after payment. Flip those to an attacker-controlled URL, and you've turned the legitimate payment confirmation page into a phishing trap. The buyer completes payment on the real gateway, gets sent to the attacker's site, and has no idea anything went wrong. That's a straight-up payment interception vector sitting on a shop's own API, open to anyone who knows a hash.
And there's a third endpoint that compounds the problem: POST /api/v2/shop/orders/{tokenValue}/payment-requests. This one creates a new payment request and resolves the target order solely from the tokenValue in the URL, again without verifying that the caller owns the order. Combined with the token leak from the GET endpoint, it's a full chain: obtain hash → leak token → create more payment requests, read any order, or redirect any buyer.
The fix — and why it took three separate mechanisms
The Sylius team patched this in versions 2.0.18, 2.1.15, and 2.2.6 (go update). But they also published an extensive workaround for anyone who can't upgrade immediately. Reading through that workaround tells you a lot about why owning these checks is architecturally tricky in a framework like Sylius. The endpoints aren't all implemented the same way, so a single authorization filter can't catch them all. The workaround grafts ownership checks onto three distinct layers: a Doctrine query extension for GET, a state provider decoration for PUT, and a command bus middleware for POST.
For GET: filter at the query level
The first piece is an ApiPlatform\Doctrine\Orm\QueryItemExtension that intercepts every GET query targeting a PaymentRequestInterface within the shop API section. It joins the payment, order, customer, and user tables and then applies a filter:
- If the user is authenticated (
ShopUserInterface), it restricts the result to orders where the order's customer matches the logged-in user's customer. - If the user is anonymous (guest checkout), it allows access only if the order has no associated user (
customer->getUser()is null) or the order was explicitly created by a guest. This ensures that an anonymous visitor can still see their own guest orders but can't snoop on a registered customer's order just by knowing a hash.
If no matching record is found, the endpoint returns a 404 — not a 403 — which is a smart move. You don't want to leak the existence of a payment request to an unauthorized caller.
For PUT: decorate the state provider
PUT uses a state provider to load the entity before updating it, so the workaround decorates the existing sylius_api.state_provider.shop.payment.payment_request.item provider with a new PaymentRequestOwnershipProvider. The decorator loads the payment request via the inner provider and then manually walks the same relationship chain: payment → order → customer → user, applying the identical ownership logic. If the user doesn't own the order, the provider returns null, which triggers an automatic 404 from API Platform.
The interesting bit here is that the decoration approach doesn't touch the Symfony security layer at all — it's purely at the data-provider level. That's clean but also easy to miss if you're only looking for voters or firewall rules.
For POST: command bus middleware
The POST creation endpoint dispatches a Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest command via the command bus, and the command's orderTokenValue comes straight from the URL. No query extension or state provider ever runs. So the workaround inserts a middleware on the sylius.command_bus that catches AddPaymentRequest commands, loads the order via the repository, and applies the same ownership rule. If the check fails, it throws a NotFoundHttpException before the handler ever runs.
This three-layer approach — query extension, state provider decoration, command bus middleware — is exactly the kind of thing that feels like overkill until you realize that a modern REST API often has multiple paths into the same data: direct reads, direct writes, and command-based mutations. If you only secure one, you leave the others wide open.
The real lesson: UUIDs are not secrets
Every time I see this class of bug, the same assumption is at work: that a random-looking identifier is a sufficient access control. It's not. UUIDs are designed for uniqueness, not confidentiality. They leak through logs, referrer headers, shared links, browser history, and a hundred other channels you can't control. If your API resolves sensitive resources solely by UUID without an ownership check, you've built a door that's only locked against people who haven't peeked at the key under the mat.
For Sylius specifically, the order token is a high-value target. It's the skeleton key to a customer's entire purchase: what they bought, where they live, their email, the cost, the shipping status. The fact that a payment request hash is enough to extract it means that the payment request endpoint was effectively a backdoor into the entire order system.
If you can't upgrade, act now
Sylius's workaround is thorough and production-tested, but it involves creating new PHP classes and wiring them into the service container. There are several steps:
- Add the query extension (
PaymentRequestOwnershipExtension). - Decorate the PUT state provider (
PaymentRequestOwnershipProvider). - Create the command bus middleware (
PaymentRequestOwnershipMiddleware). - Register all services in
services.yamlwith the correct tags and decorations. - Register the middleware on the
sylius.command_businmessenger.yaml. - Clear the cache.
The code is provided in the advisory (GHSA-mr9r-h354-966r), and it's solid — but copying it in raises its own risks: you must test that guest checkout, logged-in user orders, and admin operations all still work.
A quick checklist if you're applying this temporarily:
- Run
bin/console cache:clearafter changes and verify the container builds. - Log in as a customer with known orders and ensure you can still access your own payment requests.
- Test a guest order flow: create an order as an anonymous user, grab the payment request hash from the session, and verify you can still GET and PUT it.
- Try to access another customer's payment request (from a different account) and confirm a 404.
- Double-check the redirect paths don't break the payment flow on legitimate orders.
Better yet, just upgrade to 2.0.18+/2.1.15+/2.2.6+ if you can. The patched versions handle this natively without the extra code.
This isn't just a Sylius problem
I've seen the same pattern in dozens of ecommerce APIs — payment callbacks, order status endpoints, shipping trackers. The workflow is often: generate a random token, embed it in a URL, and assume that's authentication enough. Sometimes it's reinforced with a session check, sometimes not. But the moment that token leaks (and it will), the entire flow collapses unless there's a strong ownership check layered on top.
Payment redirection attacks like this are especially insidious because the user sees the legitimate payment gateway first, gets a success message, and then gets sent to the attacker's URL. They're already in a trusting mindset. They'll enter credentials, confirm identity, whatever — because the experience feels like a seamless part of the checkout.
If you're building any kind of financial transaction API, the lesson from this Sylius bug is simple: never trust identifiers alone. Validate that the caller owns the resource at every endpoint — read, write, and create. That means checking the authenticated user against the order's customer at the data layer, not just at the URL.
And if you're juggling multiple mutation paths (direct entity updates, command bus handlers, background jobs), audit all of them. The most secure GET endpoint in the world means nothing if your command bus will happily generate the same data for anyone who asks.
Sylius handled this well — quick disclosure, detailed workaround, clear patch. But the fact that it took three different interception points to close the loop is a reminder that authorization isn't a one-line config change. It's architecture. It's data-layer relationships. And it catches up with you fast when you skip it.
Upgrade. Test. And then go look at your own payment callback endpoints — I bet you'll find something.
Related posts
- Security
How I got free cinema credit by ordering -2 popcorns
A missing input validation on M-Tix Cinema XXI's food ordering API let me increase my account balance by submitting negative quantities. No tools needed — just a browser.
May 19, 2026 · 6 min - Security
How I analyze API security headers in 30 seconds
A quick checklist for reading HTTP response headers and spotting security misconfigurations before you even look at the response body.
May 18, 2026 · 7 min - Security
Common auth mistakes I find when reverse-engineering APIs
After years of poking at APIs that weren't meant to be poked at, these are the auth patterns that break most often — and why.
May 18, 2026 · 9 min