On a headless fashion storefront, a shopper fills a bag, opens checkout, and pays with a card inside an embedded payment form (a drop-in widget mounted from HTML the server returns). When checkout starts a payment, the commerce API responds with a short HTML snippet: a container div, JSON script tags carrying configuration, and a bootstrap script from the payment provider (the PSP). Our Next.js app sanitizes that snippet as defense in depth, then injects it so the PSP script can mount the card fields.
Who gets stuck: a shopper whose card is declined. Successful charges kept landing on the success page. Declines sent them to /undefined instead of the payment-failed screen where they could fix the card or pick another method. The bug sat quietly for roughly seven weeks because every happy-path test and every real successful order still worked.
Why the docs-shaped allowlist felt safe
Weeks earlier the sanitizer had been tightened to the attribute allowlist shown in the commerce API documentation. That was a sensible security move: only div and script tags, only named data attributes on the config script.
The docs example snippet lists data-return-url and data-payment-method. Live test-mode payloads also send data-failed-return-url, which tells the PSP where to send the shopper after a failed charge. Our unit test fixture copied the docs example, so the test proved the docs against themselves and stayed green.
sanitize-html drops attributes that are not on the allowlist without throwing. The PSP script reads dataset properties (the DOM view of data-* attributes). On success it follows data-return-url. On decline it follows data-failed-return-url. Strip that attribute and dataset.failedReturnUrl is undefined. Navigate there and the browser goes to /undefined.
Payment snippet sanitizer (toy allowlist)
Snippet before sanitize
<div id="payment-root" class="drop-in"></div>
<script id="payment-config" type="application/json"
data-return-url="/checkout/success"
data-failed-return-url="/checkout/failed"
data-payment-method="card">
{"clientKey":"…"}
</script>
<script src="https://cdn.example-psp.test/dropin.js"></script>- Attributes in captured payload
- data-return-url="/checkout/success"data-failed-return-url="/checkout/failed"data-payment-method="card"
- After sanitize
- data-return-url="/checkout/success"data-payment-method="card"
Contract test (captured payload in → out)
Fail: sanitizer dropped at least one live attribute.
Pick Simulate success or Simulate decline to see the redirect target.
Try this on the page (demo is embedded above)
- Leave Docs example selected. Click Simulate success: navigation stays
/checkout/success. Click Simulate decline: the outcome panel turns red and shows/undefined. - Switch to Captured payload, run decline again: navigation should read
/checkout/failedand the contract test banner turns green. - Optional: try data- on scripts* to see the deliberate wildcard trade-off for vendor script tags only.
What I changed in production
I added data-failed-return-url to the script allowlist so it matches what the API actually returns. Longer term I want three guardrails on any vendor HTML we sanitize:
const options = {
allowedTags: ['div', 'script'],
allowedAttributes: {
div: ['id', 'class'],
script: ['id', 'type', 'data-return-url', 'data-failed-return-url', 'data-payment-method'],
},
}
it('keeps every data-* attribute the payment snippet sends', () => {
const before = dataAttrs(capturedPaymentHtml)
const after = dataAttrs(sanitize(capturedPaymentHtml))
expect(after).toEqual(before)
})Contract-test against HTML captured from a real test-mode session, not the documentation snippet. Drive the decline path end to end with the PSP test decline card in Playwright. Fail loudly when a required redirect attribute is missing instead of calling location.assign(undefined).
When this pattern applies
Use strict allowlists for shopper- or editor-authored HTML. For trusted-but-still-sanitized vendor embeds (payments, chat, CMS blocks), either enumerate every live data-* the script reads or document a narrow data-* wildcard on script tags only. Rare branches (decline, 3-D Secure challenge, cancel) are where silent stripping hurts, because success-path monitoring never fires.
We still sanitize PSP HTML even though we trust the commerce API: the snippet passes through our server and lands in our DOM, so defense in depth is worth the extra config if the config matches reality.
Happy coding! Sander