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:
@@ -12,7 +12,9 @@ jest.mock('../../src/database/db', () => ({ db: jest.fn(), logActivity: jest.fn(
|
||||
|
||||
const {
|
||||
sanitizeCampaignBody, sanitizeCampaignCss, MAX_BODY_BYTES,
|
||||
sanitizeInlineStylesAfterSubstitution,
|
||||
} = require('../../src/services/newsletterService');
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('sanitizeCampaignBody', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
@@ -188,3 +190,46 @@ describe('sanitizeCampaignCss', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,10 +154,37 @@ function sanitizeCampaignBody(html) {
|
||||
// inside a real string, then makes the url() request — while the scanner
|
||||
// saw the apostrophe open a string and skipped everything after it. The
|
||||
// scanner has to be shown what the browser will actually parse.
|
||||
.replace(/style="([^"]*)"/gi, (match, css) => {
|
||||
const { sanitized } = sanitizeCSS(decodeHtmlEntities(css));
|
||||
return sanitized ? `style="${encodeForAttribute(sanitized)}"` : '';
|
||||
});
|
||||
.replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
|
||||
}
|
||||
|
||||
/** 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
|
||||
// company name is untrusted text and must not be able to inject markup by
|
||||
// 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 { css } = sanitizeCampaignCss(campaign.body_css);
|
||||
@@ -942,6 +974,7 @@ async function sendTest(campaignId, toEmail, adminId) {
|
||||
|
||||
module.exports = {
|
||||
sanitizeCampaignBody,
|
||||
sanitizeInlineStylesAfterSubstitution,
|
||||
sanitizeCampaignCss,
|
||||
unsubscribeToken,
|
||||
verifyUnsubscribeToken,
|
||||
|
||||
Reference in New Issue
Block a user