Type: Lab-style teaching writeup (anonymized)
Theme: Stored cross-site scripting (XSS) in a web reporting portal
Severity (lab classification): Medium — depends on cookie flags, CSP, and who views reports
Disclosure posture: Responsible methodology for education; no real vendor name, no fabricated CVE, no invented bounty amount
This writeup walks a complete teaching path: recon of the feature → sink identification → proof-of-concept pattern → impact → fix → lessons. Reproduce only in labs you own or platforms that explicitly permit XSS testing.
Summary
A reporting portal allowed contributors to submit rich descriptions that were later rendered as HTML in a moderator queue and in shared report views. Insufficient output encoding (and a permissive rich-text allow-list) let a stored script run in the browser of anyone who opened the report. The issue is classic stored XSS — dangerous because the payload waits for privileged viewers.
Recon: understanding the feature
Observed lab application behavior:
- Authenticated user opens Submit report.
- Fields: title (plain text), category (enum), description (rich text area), optional screenshot upload.
- After submit, the report appears in My reports (submitter) and Moderation (staff role).
- Staff “preview” renders the description inside the shared app chrome (same origin as admin session).
Proxy notes from an authorized lab session:
POST /api/reportsJSON includeddescriptionas an HTML string from the WYSIWYG editor.GET /api/reports/{id}returned the same HTML untouched.- Response headers on the view page lacked a strict Content-Security-Policy in the weak lab build.
Hypothesis: description is stored and reflected as HTML → stored XSS if dangerous tags/attributes survive.
Sink identification in the reporting portal
A sink is where untrusted data hits a dangerous browser API or HTML parse context.
| Location | Sink type | Evidence in lab |
|---|---|---|
| Moderation preview | HTML injection into DOM | Description mounted via innerHTML / rich component |
| Email-style notification snippet | Partial HTML | Lower impact if textified |
| Title field | Text | Encoded — not the bug |
How we confirmed (lab):
- Submit description containing a unique marker string (
anda-xss-marker). - Observe marker raw in API JSON.
- Open moderation view; marker appears as HTML source, not only text.
- Escalate marker to a minimal HTML probe (
<em>anda-xss-marker</em>) — emphasis renders → HTML allowed. - Test attribute/script vectors appropriate to the allow-list gaps.
The critical product mistake was treating the WYSIWYG output as “already safe” because the editor claimed to sanitize, while the server stored the editor’s HTML without an equivalent server-side sanitizer.
Proof-of-concept pattern (lab only)
Use benign, obvious payloads in teaching labs — for example an alert or a non-exfiltrating DOM change — so intent is unambiguous and damage is nil.
Illustrative pattern (do not fire this at production systems):
<!-- Lab PoC pattern: executes when a privileged user opens the report view -->
<img src=x onerror="window.__andraxLabXss=1">
If onerror handlers are stripped but markup like <a href="javascript:..."> or markdown-to-HTML bridges remain, adjust the probe to the surviving primitive. Document the exact surviving vector; “XSS works” without the payload class wastes the fixer.
Reproduction steps (lab):
- Log in as low-privilege reporter.
- Create report with the PoC pattern in description.
- Log in as staff (or use a second browser profile).
- Open moderation preview.
- Observe lab indicator (
window.__andraxLabXss === 1) or the harmless visual effect you chose.
Impact
Impact depends on session context of the viewer, not the submitter:
- Session hijacking risk if session cookies are not
HttpOnly/ if tokens live inlocalStorageand JS can read them. - Privileged actions as staff (approve reports, delete users, export PII) if CSRF-ish flows are script-triggerable or if APIs accept bearer tokens from JS.
- Worm potential if staff replies also re-render unsanitized HTML into other queues.
- Credibility / integrity damage even when cookies are well flagged — defacement of moderation UI, fake banners, phishing inside the trusted origin.
In the weak lab build, staff JWTs were in localStorage, so script execution implied account takeover of moderators who viewed the queue. With stronger cookie flags and CSP, impact drops but does not always hit zero (UI redress, forced actions via fetch with cookies still possible without CSP connect-src discipline).
Remediation that survives rich-text editors
Primary fix: encode for the HTML context on output, or sanitize on input and output with a vetted library and a tight allow-list (tags/attributes). Never trust the browser editor alone.
Recommended control stack:
- Server-side sanitization (e.g., allow
p,br,ul,ol,li,strong,em,a[href]with strict URL schemeshttps:/mailto:only). - Output encoding if you can store markdown and render safely instead of storing HTML.
- Content-Security-Policy default-deny scripts (
script-srcnonces/hashes; disallowunsafe-inlinein the long run). - Cookie hardening:
HttpOnly,Secure,SameSiteas appropriate; prefer server sessions over JS-readable tokens for privileged consoles. - Role-separated rendering: moderators preview untrusted HTML inside a sandboxed
iframewithcsp/sandboxflags when rich preview is mandatory. - Regression tests: CI payload corpus that must not survive round-trip to the moderation view.
Verify fix by re-running the same PoC pattern and confirming the marker is text-encoded or stripped.
Lessons
- Stored XSS is a process bug — every field that survives across sessions needs an explicit trust decision.
- WYSIWYG ≠ security control. Editors optimize for authoring; servers enforce policy.
- Privilege of the viewer sets severity. Reporter-only reflection ≠ staff queue reflection.
- Defense in depth matters. Encoding + CSP + HttpOnly together beat any single control.
- Writeups should teach the sink. Payload golf without sink/context analysis trains the wrong muscle.
Responsible practice
- Test only on labs or programs that allow XSS.
- Use non-destructive payloads; never exfiltrate real user data.
- If you find a similar issue on a real program, follow that program’s disclosure rules — this page is not a template for unauthorized testing.
