Make inline email images clickable — open full resolution in a new tab

A bare <img> in a rendered email body wasn't wrapped in any link, so the
only way to see it full-size was the browser's right-click "open image in
new tab". sanitizeEmailHtml now wraps every image that isn't already inside
a real link (a sender-linked banner is left alone, no double-wrapping) in
<a href={its own src} target="_blank" rel="noopener noreferrer"> — a normal
left click, middle click, or ctrl-click all now do the expected thing.

The wrapping step (lib/mail/sanitize-html.ts, using the new cheerio dep)
builds the <a> via .attr() rather than string-interpolating the src into an
HTML template — sanitize-html only validates an img src's URL *scheme*, not
that a data: URI's declared content is actually image data, so a crafted
src could otherwise contain characters that break out of an attribute if
naively concatenated into HTML text for re-parsing. Covered by 3 new tests
in sanitize-html.test.ts, including that exact injection attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-19 08:43:19 +00:00
1 parent cabcc0b1d4
commit 1a09903326
5 files changed
+254 -5

No files matched your search

+4 -4
View File
@@ -1,11 +1,11 @@
// message.bodyHtml is already sanitized server-side (see
// lib/mail/sanitize-html.ts) before it's ever stored — this only adds
// layout containment so an email's inline styles/tables can't blow out the
// message bubble's width.
// lib/mail/sanitize-html.ts), including wrapping bare images in a link to
// their own full-size src — this only adds layout containment so an
// email's inline styles/tables can't blow out the message bubble's width.
export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) {
return (
<div
className={`max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`}
className={`max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_img]:cursor-zoom-in [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
+27
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import * as cheerio from "cheerio";
import { sanitizeEmailHtml } from "./sanitize-html";
describe("sanitizeEmailHtml", () => {
@@ -47,4 +48,30 @@ describe("sanitizeEmailHtml", () => {
expect(out).not.toContain("behavior:url");
expect(out).toContain("Hi");
});
it("wraps a bare image in a link to its own full-size src, opened in a new tab", () => {
const out = sanitizeEmailHtml('<img src="data:image/png;base64,iVBORw0KGgo=" alt="shot">');
expect(out).toMatch(/<a href="data:image\/png;base64,iVBORw0KGgo="[^>]*target="_blank"[^>]*><img[^>]*><\/a>/);
});
it("does not double-wrap an image the sender already linked themselves", () => {
const out = sanitizeEmailHtml('<a href="https://example.com/landing"><img src="https://example.com/banner.png"></a>');
const anchorCount = (out.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(1);
expect(out).toContain('href="https://example.com/landing"');
});
it("safely wraps an image whose src contains characters that could break out of an HTML attribute", () => {
// sanitize-html itself only checks the URL scheme on a data: src, not
// that the payload is actually image data — the wrap step must not
// re-parse that value as HTML when building the wrapping <a>. A raw
// "<script>" substring inside a properly double-quoted attribute value
// is inert (browsers only end the attribute on the matching quote, not
// on ">"), so the real check is "no actual <script> element exists
// when the output is parsed as HTML", not a plain substring match.
const evilSrc = 'data:text/plain,"><script>alert(1)</script>';
const out = sanitizeEmailHtml(`<img src="${evilSrc.replace(/"/g, "&quot;")}">`);
const $ = cheerio.load(out, null, false);
expect($("script").length).toBe(0);
});
});
+32 -1
View File
@@ -1,4 +1,5 @@
import sanitizeHtml from "sanitize-html";
import * as cheerio from "cheerio";
// Formatting-only tags — no <script>/<style>/<iframe>/<object>/<form>/svg/etc.
// Unknown tags (Outlook's <o:p>, VML <v:*>) are unwrapped by sanitize-html
@@ -38,7 +39,7 @@ const ALLOWED_STYLES = {
* allowed URL schemes, so any that slip through as-is are dropped.
*/
export function sanitizeEmailHtml(html: string): string {
return sanitizeHtml(html, {
const clean = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: {
a: ["href", "title", "target", "rel"],
@@ -55,4 +56,34 @@ export function sanitizeEmailHtml(html: string): string {
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
},
});
return wrapBareImagesInLinks(clean);
}
/**
* A plain <img> with no href around it isn't clickable — the message
* bubble shows it shrunk to fit, and the only way to see it at full
* resolution was the browser's right-click "open image in new tab". Wrap
* every image not already inside a real link (a sender-linked banner, say)
* in one pointing at its own src, so a normal click does the same thing.
* Runs after sanitizeHtml, on already-sanitized output — the href is just
* the img's own already-scheme-validated src, so this can't reintroduce
* anything sanitizeHtml would have stripped.
*/
function wrapBareImagesInLinks(html: string): string {
const $ = cheerio.load(html, null, false);
$("img").each((_, el) => {
const $img = $(el);
if ($img.closest("a").length > 0) return;
const src = $img.attr("src");
if (!src) return;
// Built via .attr(), not string-interpolated HTML — src is
// attacker-influenced (an already scheme-validated but otherwise
// arbitrary data:/http(s) URL), and interpolating it into an HTML
// string for cheerio to re-parse would let a crafted `src` (e.g. one
// containing an embedded `">`) break out of the attribute and inject
// markup. Setting it as a DOM attribute value keeps it inert.
const $a = $("<a>").attr({ href: src, target: "_blank", rel: "noopener noreferrer" });
$img.wrap($a);
});
return $.html();
}