September 4, 2026
Stored Cross-User XSS via Double-Decode Sanitizer Bypass in Reaction Notifications
By: Youssefashraf50
By Joashraf
3 min read
โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ
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:
- Client sends reaction code (e.g.
1f600โ ๐) - Server stores the reaction and notifies User B
- 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;// 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 `` }
}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โฆ"
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: < โ > โ > Result: "" (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;//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 (previously reported)
- Status: Remediation in progress
This writeup intentionally omits the platform name and specific endpoints to support responsible disclosure while the fix is being applied.