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 {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user