Bug Hunting Journey

Writeups XSS

XSS

Three characters that walked through the filename filter

Severity High CVSS 7.4
Impact
Session theft across a shared workspace
Bounty
$1k - $2.5k
Program
Private

TL;DR

The app escaped a filename correctly on upload, then re-normalised it to NFKC before injecting it into the preview - which resurrected characters that had just been escaped. Stored XSS firing for every member of the workspace.

Stored XSS in a filename, in 2026, sounds unlikely. It is nonetheless what ███████████████ shipped a fix for last month, and the bug came down to three characters nobody had thought to test together.

The behaviour

The app accepts attachments in shared workspaces. The filename is shown in the list, in the preview, and in the notification sent to other members. On upload, the server applies perfectly correct HTML escaping:

POSTUploadrequest
POST /api/files HTTP/2Host: app.target.tldContent-Type: multipart/form-data; boundary=----x------xContent-Disposition: form-data; name="file"; filename="<img src=x onerror=alert(1)>.pdf"

In the list, the name comes back properly as &lt;img src=x onerror=alert(1)&gt;.pdf. Apparent dead end.

What unblocks it

The preview, however, runs the name through a “display cleanup” function that normalises to NFKC so Unicode variants line up. NFKC turns certain compatibility characters into their ASCII equivalents. Send the fullwidth variants < and > (U+FF1C and U+FF1E) and the upload-time escaping sees nothing wrong - those are not angle brackets - then normalisation converts them back to < and >, after the escaping has already run.

The bug, reduced to its smallest formJavaScript
const safe = escapeHtml(file.name);          // <img …> passes through untouched// … stored, then, when rendering the preview:const shown = safe.normalize('NFKC');        // < becomes < againpreview.innerHTML = `<span>${shown}</span>`; // stored XSS

Takeaways

  • Normalising after escaping undoes the escaping. The order of those two operations is the entire vulnerability; each function, read on its own, is beyond reproach.
  • A field escaped once is not escaped everywhere. Here the list was safe and the preview was not, for the same piece of data.
  • Testing the Unicode variants of <, > and " should be reflex on any field that crosses more than one rendering component.

Disclosure timeline

  1. Report sent
  2. Triaged high severity
  3. Fix deployed escaping moved to after normalisation
  4. Cleared for publication