From 440a2e7ac7a8cf929cc0b2fd3bdb3fa6d62597b7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:29:23 +0100 Subject: [PATCH] fix: scrub only real stylesheets and keep their css escapes when blocking remote content --- src/lib/__tests__/email-html.test.ts | 33 +++++ src/lib/email-html.ts | 172 +++++++++++++++++---------- 2 files changed, 143 insertions(+), 62 deletions(-) diff --git a/src/lib/__tests__/email-html.test.ts b/src/lib/__tests__/email-html.test.ts index 268dd00c..415ac4ec 100644 --- a/src/lib/__tests__/email-html.test.ts +++ b/src/lib/__tests__/email-html.test.ts @@ -187,6 +187,39 @@ describe('blockExternalResources', () => { expect(html).not.toMatch(/<\/style>\s*` in + // one attribute and a `` in a later one made it "scrub" the + // attributes between them, decoding the CSS escapes into real quotes. + it('scrubs only real stylesheets, not text inside attribute values', () => { + const input = String.raw`click

z

`; + const { html } = prepareEmailHtml(input, { blockRemoteImages: true, isDark: false }); + expect(html).not.toMatch(/href="javascript:/i); + expect(html).toContain(String.raw`click`); + }); + + it('cuts external URLs out of a sheet and leaves its escapes as written', () => { + const css = String.raw`a{background:url(https://x/)} b{background:\5c 75rl(https://y/)} c{background:\75rl(https://z/)} d{content:"\"\3c /style\3e"}`; + const { html, blocked } = blockExternalResources(``); + expect(blocked).toBe(true); + // `\5c 75rl(` is the text `\75rl(`, not a url(): written decoded it + // became one. The escaped quote stays escaped. + expect(html).toBe(String.raw``); + }); + + it('reads an escape past the last code point as U+FFFD instead of throwing', () => { + expect(decodeCssEscapes('\\FFFFFF')).toBe('\ufffd'); + expect(() => blockExternalResources('')).not.toThrow(); + }); + + it('finds images the parser reads as , and entity-encoded URLs', () => { + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + const { html } = blockExternalResources(''); + expect(html).toContain('data-blocked-src="https://tracker.example/p.gif"'); + }); + it('finds the image behind a stray quote, as the browser does', () => { const { html, blocked } = blockExternalResources(''); expect(blocked).toBe(true); diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index f7e9cf9b..032f19c7 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -200,10 +200,15 @@ function serializeTag(name: string, attrs: ParsedAttr[], selfClosing: boolean): * the browser keeps them) and returns the list to write, `null` to keep the * attributes unchanged, or `false` to drop the tag. End tags are written * without attributes; comments, doctypes and bogus comments are dropped. + * `rawText`, when given, rewrites the text of each kept raw-text element + * (a `