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:
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 <img src=x onerror=alert(1)>.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.
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 XSSTakeaways
- 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
- Report sent
- Triaged high severity
- Fix deployed escaping moved to after normalisation
- Cleared for publication