From Image URL to Stored XSS: A Post-Sanitization Bug in Zero
#On this page
What happens when sanitized HTML is modified and parsed again?
In Zero, an open-source AI email client, the answer was stored XSS.
A malicious HTML email could execute JavaScript in Zero's authenticated web origin when opened by a user who blocked external images from the sender. That last condition is the fun part: the feature meant to make the email safer was the trigger.
The initial sanitizer worked. The vulnerability was introduced afterward, when Zero interpolated a decoded image URL into a new HTML comment and rendered the result with innerHTML.
How image blocking turned a URL into markup
Email HTML is untrusted, so Zero sanitized each message before rendering it. The sanitizer correctly removed event handlers and other executable markup.
Then image blocking performed a second transformation.
For each external image, Zero read its src and replaced the image with a hidden placeholder. The original URL was preserved inside an HTML comment:
$img.replaceWith(
`<span style="display:none;"><!-- blocked image: ${src} --></span>`,
);That comment was created after sanitization, and the email sender controlled src. In HTML, --> closes a comment. A URL containing that sequence could end the comment early and turn everything after it back into markup.
Zero later assigned the transformed string to innerHTML, causing the browser to parse it again.
So the full chain was:
- The sanitizer cleaned the original email
- Image blocking copied a decoded, attacker-controlled URL into new HTML
-->escaped the generated commentinnerHTMLparsed the remaining text as an element
The sanitizer did not miss the malicious element. The element did not exist until Zero constructed new HTML after the sanitizer had finished.
Image blocking, briefly becoming the thing it was supposed to prevent:

Sanitization is not a permanent status effect. If an application constructs new HTML afterward, the sanitizer's guarantee no longer applies.
One image, one bad comment
I sent an HTML email containing:
<p>blocked-image breakout test</p>
<img
src="https://example.com/--><img src=x onerror=alert(1)"
width="2"
>When Zero blocked the external image, it generated HTML equivalent to:
<span style="display:none;">
<!-- blocked image: https://example.com/-->
<img src=x onerror=alert(1) -->
</span>The first --> closed the comment. The remaining text became an <img> element with an onerror handler. HTML comments, it turns out, are not safe storage for attacker-controlled strings.
The reproduction steps were:
- Send the payload as an HTML email
- Receive it in a Gmail mailbox connected to Zero
- Leave external images disabled for the sender
- Open the message in Zero
- Observe JavaScript execution
I reproduced the issue against upstream commit 2ada920 and against 0.email using my own account.
Opening the crafted email with external images blocked triggered JavaScript in the authenticated 0.email origin.
Why the execution context mattered
Opening the email ran JavaScript inside Zero's authenticated web origin, not an isolated email viewer. The browser would automatically include the victim's session in same-origin requests, allowing the payload to call the application's backend as the logged-in user.
I confirmed that the injected script could reach authenticated routes, including Zero's AI endpoint. The AI request passed authentication and failed only because my local environment lacked an OpenAI API key. I also identified an authenticated action for sending mail, but did not invoke it.
The demonstrated impact was stored JavaScript delivered through an email with access to the victim's authenticated application context.
The fix: stop carrying the dangerous value
Zero removed the original image URL from the placeholder:
const $placeholder = $('<span></span>');
$placeholder.attr('data-blocked-image', 'true');
$placeholder.attr('style', 'display:none;');
$img.replaceWith($placeholder);The placeholder did not need the original URL, so the patch used the best encoding strategy: don't put it back.
The patch also added a focused regression test using the reported payload. It verifies that:
- External images are removed
- No
onerrorattribute survives - No
alert(1)text survives - No attacker-controlled HTML comment is generated
- The fixed placeholder is present
Credit to the Zero maintainers for addressing the report quickly and preserving the exploit primitive in a regression test.
What I would carry into the next review
- Sanitization only protects the representation and context it produces. Later decoding and HTML construction can invalidate it.
- If HTML is transformed after sanitization, sanitize the final result before rendering.
- Prefer structured element construction over raw HTML strings.
- Do not retain attacker-controlled data that the feature does not need.
Disclosure timeline
- May 24, 2026: Privately reported the vulnerability with source analysis, a working email proof of concept, impact details, and remediation guidance
- May 26, 2026: Zero committed the fix and regression tests in
7e0088d - June 30, 2026: Public disclosure
