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:
1 parent
cabcc0b1d4
commit
1a09903326
5 files changed
+254
-5
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user