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
Generated
+190
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"argon2": "^0.45.1",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"framer-motion": "^12.4.7",
|
||||
"html-to-text": "^10.0.0",
|
||||
@@ -4022,6 +4023,11 @@
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
@@ -4255,6 +4261,46 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
@@ -4329,6 +4375,32 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -4747,6 +4819,29 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -7430,6 +7525,17 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -7651,6 +7757,51 @@
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||
@@ -9702,6 +9853,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -10058,6 +10217,37 @@
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"argon2": "^0.45.1",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"framer-motion": "^12.4.7",
|
||||
"html-to-text": "^10.0.0",
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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, """)}">`);
|
||||
const $ = cheerio.load(out, null, false);
|
||||
expect($("script").length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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