fix(security): re-check inline CSS after template substitution

The fourth bypass found in this review, and the one no lexer fix
reaches: sanitizing runs on the stored body, but safeTemplateReplace
rewrites it afterwards, so the string that was validated is not the
string that is sent.

A conditional inside a style attribute can delete the very quoting that
made a url() inert:

  style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)"

At write time the url() genuinely sits inside a CSS string and is
correctly left alone. Expanding the conditional for a recipient with no
company name removes both quotes and the background goes live —
confirmed end to end against the real functions.

The style-attribute pass now runs again on the substituted output.
Substitution cannot introduce a `"` (values are HTML-escaped), so the
attribute match still holds. body_css is not substituted, so the
<style> block cannot be rewritten after its check and needs nothing.

This is the case the removed newsletter pass had been covering. Rather
than reinstating a second definition of "disallowed", the one definition
now runs at both points where the content changes.

Refs #1264
This commit is contained in:
Paul Nothaft
2026-09-05 15:21:40 +02:00
parent 4196e83a5f
commit 027afb6086
2 changed files with 83 additions and 5 deletions
@@ -12,7 +12,9 @@ jest.mock('../../src/database/db', () => ({ db: jest.fn(), logActivity: jest.fn(
const { const {
sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES, sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES,
sanitizeInlineStylesAfterSubstitution,
} = require('../../src/services/newsletterService'); } = require('../../src/services/newsletterService');
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
describe('sanitizeCampaignBody', () => { describe('sanitizeCampaignBody', () => {
it('returns empty string for empty input', () => { it('returns empty string for empty input', () => {
@@ -188,3 +190,46 @@ describe('sanitizeCampaignCss', () => {
expect(css).not.toContain('</style>'); expect(css).not.toContain('</style>');
}); });
}); });
describe('inline CSS is re-checked after template substitution', () => {
// The stored body is sanitized, but safeTemplateReplace rewrites it
// afterwards — so the string that was validated is not the string that is
// sent. A conditional inside a style attribute can delete the quoting that
// made a url() inert, which no amount of lexer correctness can catch.
const payload =
`<p style="--x:x{{#if company_name}}'{{/if}};`
+ `background:url(https://evil.example/p.gif);`
+ `--y:x{{#if company_name}}'{{/if}}">hi</p>`;
it('neutralises a url() that substitution would activate', () => {
const stored = sanitizeCampaignBody(payload);
// Correctly left alone at write time: the url() really is inside a CSS
// string while the conditionals are still in place.
expect(stored).toContain('evil.example');
const substituted = safeTemplateReplace(stored, { company_name: '' }, { escapeHtml: true });
// Expansion removed the quotes, so without the recheck this ships live.
expect(substituted).toMatch(/background:url\(https:\/\/evil\.example/);
const rendered = sanitizeInlineStylesAfterSubstitution(substituted);
expect(rendered).not.toMatch(/url\(\s*['"]?https:\/\/evil\.example/);
expect(rendered).toContain('background:none');
});
it('leaves a body without style attributes untouched', () => {
const html = '<p>Hello {{first_name}}</p>';
expect(sanitizeInlineStylesAfterSubstitution(html)).toBe(html);
});
it('keeps legitimate inline styles through the recheck', () => {
const html = '<p style="color:red;font-size:14px">hi</p>';
const out = sanitizeInlineStylesAfterSubstitution(html);
expect(out).toContain('color:red');
expect(out).toContain('font-size:14px');
});
it('is safe on empty and nullish input', () => {
expect(sanitizeInlineStylesAfterSubstitution('')).toBe('');
expect(sanitizeInlineStylesAfterSubstitution(null)).toBeNull();
});
});
+38 -5
View File
@@ -154,10 +154,37 @@ function sanitizeCampaignBody(html) {
// inside a real string, then makes the url() request — while the scanner // inside a real string, then makes the url() request — while the scanner
// saw the apostrophe open a string and skipped everything after it. The // saw the apostrophe open a string and skipped everything after it. The
// scanner has to be shown what the browser will actually parse. // scanner has to be shown what the browser will actually parse.
.replace(/style="([^"]*)"/gi, (match, css) => { .replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
const { sanitized } = sanitizeCSS(decodeHtmlEntities(css)); }
return sanitized ? `style="${encodeForAttribute(sanitized)}"` : '';
}); /** Shared by the write-time sanitize and the post-substitution recheck. */
const STYLE_ATTRIBUTE = /style="([^"]*)"/gi;
function sanitizeStyleAttribute(match, css) {
const { sanitized } = sanitizeCSS(decodeHtmlEntities(css));
return sanitized ? `style="${encodeForAttribute(sanitized)}"` : '';
}
/**
* Re-check inline CSS AFTER template substitution.
*
* Sanitizing runs on the stored body, but `safeTemplateReplace` rewrites it
* afterwards — so the string that was validated is not the string that gets
* sent. A conditional inside a style attribute can delete the very characters
* that made a URL inert:
*
* style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)"
*
* At sanitize time the url() sits inside a CSS string and is correctly left
* alone; once the conditional is expanded the quotes are gone and the
* background is live. No amount of lexer correctness fixes that, because the
* text being lexed is not the text being delivered — the check has to run
* again on the final output. Substitution cannot introduce a `"` (values are
* HTML-escaped), so the attribute regex still matches what it should.
*/
function sanitizeInlineStylesAfterSubstitution(html) {
if (!html) return html;
return String(html).replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
} }
/** /**
@@ -330,7 +357,12 @@ async function renderForRecipient(campaign, customer, options = {}) {
// Substitution happens AFTER sanitizing, with escaping on: a customer's own // Substitution happens AFTER sanitizing, with escaping on: a customer's own
// company name is untrusted text and must not be able to inject markup by // company name is untrusted text and must not be able to inject markup by
// riding in through a variable the sanitizer never saw. // riding in through a variable the sanitizer never saw.
const body = safeTemplateReplace(safeBody, variables, { escapeHtml: true }); // Re-checked after substitution, not only before it: expansion can remove
// the quoting that made a url() inert at sanitize time. See
// sanitizeInlineStylesAfterSubstitution.
const body = sanitizeInlineStylesAfterSubstitution(
safeTemplateReplace(safeBody, variables, { escapeHtml: true })
);
const subject = safeTemplateReplace(campaign.subject || '', variables); const subject = safeTemplateReplace(campaign.subject || '', variables);
const { css } = sanitizeCampaignCss(campaign.body_css); const { css } = sanitizeCampaignCss(campaign.body_css);
@@ -942,6 +974,7 @@ async function sendTest(campaignId, toEmail, adminId) {
module.exports = { module.exports = {
sanitizeCampaignBody, sanitizeCampaignBody,
sanitizeInlineStylesAfterSubstitution,
sanitizeCampaignCss, sanitizeCampaignCss,
unsubscribeToken, unsubscribeToken,
verifyUnsubscribeToken, verifyUnsubscribeToken,