Sitemap

Stored Cross-User XSS via Double-Decode Sanitizer Bypass in Reaction Notifications

4 min readSep 4, 2026

--

By: Youssefashraf50

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Hello Hackers,

I’m youssef, a bug bounty hunter

While testing a project management platform’s comment reaction feature, I discovered a stored cross user XSS vulnerability that could execute arbitrary JavaScript in any workspace member’s browser simply by sending a crafted reaction.

What made this finding interesting wasn’t just the XSS itself, but the unusual attack chain: a double-decode sanitizer bypass that allowed malicious HTML to survive DOMPurify completely undetected.

How Reactions Work

When User A reacts to User B’s comment:

  1. Client sends reaction code (e.g. 1f600 → 😀)
  2. Server stores the reaction and notifies User B
  3. B opens notifications → sees “A reacted 😀 to your comment: [comment text]”

Simple enough. But the vulnerability lives in how that notification gets rendered.

Finding the Sink

While doing static analysis on the platform’s JavaScript bundle, I searched for dangerous rendering patterns:

This is what I was looking for
→ dangerouslySetInnerHTML={{__html: someVariable}}

I found the notification renderer doing something like this:

// Simplified version of what the code does
const reactionEmoji = dme(metadata.reactionCodes); // decode reaction
const commentText = metadata.commentText; // victim’s comment
const rendered = sanitizeAndDecode(
`${reactionEmoji} to your comment: ${commentText}`
);
// Then rendered via innerHTML
element.innerHTML = rendered;

Two things immediately stood out:

dme() was decoding the reaction code into a string

The result went through sanitize → then decode → then innerHTML

The Vulnerable Function: dme()

function dme(e) { 
if (!e || e.length === 0) return ``;
let t = e[0].value.split(`-`);
let n = [];
t.forEach(e => n.push(`0x${e}`));
try { return String.fromCodePoint(…n) }
catch { return `` }
}

This function takes reactionCodes[0].value, splits by -, and converts hex codepoints to characters.

Normal input: "1f600" → 😀

But what if the input was: "26-6c-74-3b-69-6d-67-20-73-72-63-3d-78-20-6f-6e-65-72-72-6f-72-3d-61-6c-65-72-74-28-31-29-3e"?

That decodes to: <img src=x onerror=alert(1)>

The Double-Decode Bypass

This is the core of the vulnerability. Here’s what happens step by step:

1- Attacker sends malicious reaction value: value = “26–6c-74–3b-69–6d-67…”

Get Joashraf’s stories in your inbox

Join Medium for free to get updates from this writer.

2- dme() decodes hex to characters: “<img src=x onerror=alert(1)>” (looks like text, not markup)

3- DOMPurify sanitizes: Input contains < not DOMPurify sees TEXT, not an HTML tag Result: “<img src=x onerror=alert(1)>” ← UNCHANGED ✅

4- replaceCharacters() decodes entities: &lt; → &gt; → > Result: “<img src=x onerror=alert(1)>” (now it’s real markup!)

5- innerHTML renders it: Browser parses as HTML → onerror fires → ⟮XSS⟯

Why did DOMPurify fail?

DOMPurify is not broken it did its job perfectly. It sanitized what it saw, which was harmless text. The problem is that a second decode happened after sanitization, turning that harmless text into live HTML.

This is a classic sanitize-then-decode vulnerability pattern.

Why This Is Dangerous?

  • Attacker role: Any workspace member (lowest privilege)
  • Victim: Any other workspace member
  • Victim interaction: Just opening notifications
  • Impact: JS execution in victim’s browser

Any authenticated member could target any other member including admins and owners without the victim doing anything beyond opening their notification panel

The Root Cause

The vulnerability has two root causes working together:

1. Missing server-side validation
The reaction value field accepted arbitrary hex strings with no allowlist enforcement. A valid reaction should only accept predefined emoji codepoints.

2. Decode after sanitize
The rendering pipeline applied DOMPurify first, then decoded HTML entities. This order is fundamentally unsafe decoding must happen before sanitization, never after.

Fix Recommendations:

//Wrong order (vulnerable)
const sanitized = DOMPurify.sanitize(input);
const decoded = decodeEntities(sanitized);
element.innerHTML = decoded;
//Correct approach
//Use textContent instead of innerHTML for text
element.textContent = input;
//OR if HTML is needed, decode first then sanitize
const decoded = decodeEntities(input);
const sanitized = DOMPurify.sanitize(decoded);
element.innerHTML = sanitized;

Server-side:
Validate reaction values against a strict allowlist of supported emoji codepoints before storing 😀

Key Takeaway

DOMPurify doesn’t protect you if something decodes the output after it runs.

Always ask: “Is anything touching this string after sanitization?”

If yes that’s your attack surface.

Timeline

  • Discovery: September 2026
  • Reported: September 2026
  • Response: Duplicate
Press enter or click to view image in full size

--

--