diff --git a/backend/src/services/newsletterService.js b/backend/src/services/newsletterService.js index 46da3528..eaec1f76 100644 --- a/backend/src/services/newsletterService.js +++ b/backend/src/services/newsletterService.js @@ -141,44 +141,24 @@ function sanitizeCampaignBody(html) { }, }) // sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each. + // sanitizeCSS blocks remote url() properly as of #1290 — it lexes the CSS + // rather than pattern-matching it, so the local pass this used to need is + // gone. Keeping a second copy would mean two definitions of "disallowed" + // drifting apart. .replace(/style="([^"]*)"/gi, (match, css) => { const { sanitized } = sanitizeCSS(css); - const cleaned = stripRemoteCssUrls(sanitized); - return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : ''; + return sanitized ? `style="${sanitized.replace(/"/g, '')}"` : ''; }); } -/** - * Remove every `url(...)` that is not an inline data: image. - * - * The shared `sanitizeCSS` *detects* a remote url() and prefixes it with a - * `/* BLOCKED URL *\/` comment — but a CSS comment is stripped during - * tokenization, so the declaration a mail client actually parses still - * carries the live URL. Verified: - * - * sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized - * → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}' - * - * In a newsletter that is a tracking pixel delivered to every recipient, so - * this pass actually removes the token. Scoped to the newsletter path on - * purpose: the same weakness affects gallery custom CSS, but changing shared - * sanitizer behaviour is a separate change with its own blast radius. - */ -function stripRemoteCssUrls(css) { - if (!css) return ''; - return String(css) - .replace(/\/\*\s*BLOCKED URL\s*\*\//gi, '') - .replace(/url\s*\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (match, _quote, target) => - (/^data:image\/(?:jpeg|jpg|png|gif|webp)/i.test(target.trim()) ? match : 'none')); -} - /** * Sanitize a campaign's optional `