Merge remote-tracking branch 'origin/main' into codex/1110-usage-coverage
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Image-security settings applied as creation defaults (#1296).
|
||||
*
|
||||
* Four controls in Settings → Image security were written, reloaded and
|
||||
* rendered as toggles, and read by nothing:
|
||||
*
|
||||
* default_protection_level, default_image_quality,
|
||||
* enable_canvas_rendering
|
||||
*
|
||||
* Each maps onto an `events` column migration 038 already created, and each
|
||||
* is labelled "… by default". `enable_devtools_protection` was the only one
|
||||
* of the five ever wired.
|
||||
*
|
||||
* The load-bearing constraint is that this is CREATION-time only. Applying
|
||||
* these to existing events would silently change live galleries on upgrade —
|
||||
* an install with enable_canvas_rendering already on would flip every grid to
|
||||
* canvas rendering, which is the memory profile under investigation in #1287.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('image-security creation defaults', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let getImageSecurityDefaults;
|
||||
let resolveImageSecurityColumns;
|
||||
let readBooleanSetting;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ getImageSecurityDefaults, resolveImageSecurityColumns, readBooleanSetting } =
|
||||
require('../../src/routes/adminEvents/helpers'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const setSetting = async (key, value) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'security' })
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').whereIn('setting_key', [
|
||||
'default_protection_level', 'default_image_quality',
|
||||
'enable_canvas_rendering',
|
||||
]).del();
|
||||
});
|
||||
|
||||
it('returns nothing when no settings are configured', async () => {
|
||||
// Every key absent must fall through to the column defaults, which is
|
||||
// exactly the behaviour before this existed.
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('maps each setting onto its events column', async () => {
|
||||
await setSetting('default_protection_level', 'enhanced');
|
||||
await setSetting('default_image_quality', 72);
|
||||
await setSetting('enable_canvas_rendering', true);
|
||||
|
||||
expect(await getImageSecurityDefaults()).toEqual({
|
||||
protection_level: 'enhanced',
|
||||
image_quality: 72,
|
||||
use_canvas_rendering: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a false canvas setting through, rather than dropping it', async () => {
|
||||
// `false` is a real choice — dropping it as falsy would leave the column
|
||||
// default in place and make "off" unreachable.
|
||||
await setSetting('enable_canvas_rendering', false);
|
||||
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown protection level', 'default_protection_level', 'paranoid'],
|
||||
['a non-enum protection level', 'default_protection_level', 42],
|
||||
['image quality above 100', 'default_image_quality', 250],
|
||||
['image quality of zero', 'default_image_quality', 0],
|
||||
['a non-numeric quality', 'default_image_quality', 'high'],
|
||||
['a non-boolean canvas value', 'enable_canvas_rendering', 'yes'],
|
||||
// parseInt would have rescued each of these into a valid-looking
|
||||
// integer. The settings PUT stores values without validating them, so
|
||||
// they can genuinely be in the table.
|
||||
['a numeric prefix with trailing junk', 'default_image_quality', '72oops'],
|
||||
['a fractional quality', 'default_image_quality', 72.5],
|
||||
['a single-element array', 'default_image_quality', [72]],
|
||||
])('ignores %s and falls through to the column default', async (_label, key, value) => {
|
||||
await setSetting(key, value);
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('applies only the keys that are configured', async () => {
|
||||
await setSetting('default_protection_level', 'maximum');
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('never throws, so a settings failure cannot block event creation', async () => {
|
||||
await setSetting('default_image_quality', { nonsense: true });
|
||||
await expect(getImageSecurityDefaults()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
describe('double-encoded settings (the settings tab round trip)', () => {
|
||||
// GET returns setting_value undecoded and the tab PUTs the whole object
|
||||
// back through JSON.stringify, so on SQLite one visit to the tab turns
|
||||
// every value it read into a doubly-encoded string. A single parse left
|
||||
// a string behind, the type checks rejected it, and the defaults went
|
||||
// silently dead again.
|
||||
const setRaw = async (key, raw) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
|
||||
.onConflict('setting_key').merge();
|
||||
};
|
||||
|
||||
it('reads a double-encoded boolean', async () => {
|
||||
await setRaw('enable_canvas_rendering', JSON.stringify(JSON.stringify(true)));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ use_canvas_rendering: true });
|
||||
});
|
||||
|
||||
it('reads a double-encoded protection level', async () => {
|
||||
await setRaw('default_protection_level', JSON.stringify(JSON.stringify('enhanced')));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'enhanced' });
|
||||
});
|
||||
|
||||
it('reads a double-encoded integer', async () => {
|
||||
await setRaw('default_image_quality', JSON.stringify(JSON.stringify(72)));
|
||||
expect(await getImageSecurityDefaults()).toEqual({ image_quality: 72 });
|
||||
});
|
||||
|
||||
it('reads a value buried under many saves, not just one', async () => {
|
||||
// Each visit to the settings tab used to add a layer, so the depth is
|
||||
// however many times someone opened it — not a number to cap.
|
||||
let raw = JSON.stringify('maximum');
|
||||
for (let i = 0; i < 8; i += 1) raw = JSON.stringify(raw);
|
||||
await setRaw('default_protection_level', raw);
|
||||
expect(await getImageSecurityDefaults()).toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('still rejects a malformed value however many times it was encoded', async () => {
|
||||
await setRaw('default_image_quality', JSON.stringify(JSON.stringify('72oops')));
|
||||
expect(await getImageSecurityDefaults()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBooleanSetting shares the same decoder', () => {
|
||||
// Every reader of app_settings has to agree, or the settings tab shows
|
||||
// protection disabled while newly created galleries turn it on.
|
||||
const setRaw2 = async (key, raw) => {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: key, setting_value: raw, setting_type: 'security' })
|
||||
.onConflict('setting_key').merge();
|
||||
};
|
||||
|
||||
it('reads a double-encoded false as false, not as absent', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify(JSON.stringify(false)));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBe(false);
|
||||
});
|
||||
|
||||
it('still reads a singly-encoded value', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify(true));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined for a non-boolean, so the caller keeps its default', async () => {
|
||||
await setRaw2('enable_devtools_protection', JSON.stringify('sometimes'));
|
||||
expect(await readBooleanSetting('enable_devtools_protection')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveImageSecurityColumns', () => {
|
||||
it('omits every column when neither the request nor the settings supply one', () => {
|
||||
expect(resolveImageSecurityColumns({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('uses the global default when the request says nothing', () => {
|
||||
expect(resolveImageSecurityColumns({}, { protection_level: 'maximum' }))
|
||||
.toEqual({ protection_level: 'maximum' });
|
||||
});
|
||||
|
||||
it('lets an explicit request value win over the global default', () => {
|
||||
expect(resolveImageSecurityColumns(
|
||||
{ protection_level: 'basic' },
|
||||
{ protection_level: 'maximum' },
|
||||
)).toEqual({ protection_level: 'basic' });
|
||||
});
|
||||
|
||||
it('keeps an explicit false canvas value instead of reading it as absent', () => {
|
||||
const columns = resolveImageSecurityColumns(
|
||||
{ use_canvas_rendering: false },
|
||||
{ use_canvas_rendering: true },
|
||||
);
|
||||
expect(columns.use_canvas_rendering).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps a zero-ish explicit value rather than falling through', () => {
|
||||
// 0 is out of range for the column, but the guard is `!== undefined`,
|
||||
// not truthiness — the validator is what rejects out-of-range input.
|
||||
expect(resolveImageSecurityColumns({ image_quality: 0 }, { image_quality: 85 }))
|
||||
.toEqual({ image_quality: 0 });
|
||||
});
|
||||
|
||||
it('resolves each column independently', () => {
|
||||
expect(resolveImageSecurityColumns(
|
||||
{ image_quality: 60 },
|
||||
{ protection_level: 'enhanced' },
|
||||
)).toEqual({
|
||||
protection_level: 'enhanced',
|
||||
image_quality: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a missing body, which is what an empty API request looks like', () => {
|
||||
expect(resolveImageSecurityColumns(undefined, { image_quality: 90 }))
|
||||
.toEqual({ image_quality: 90 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,43 @@ describe('admin events CRUD endpoints (smoke)', () => {
|
||||
expect(row.welcome_message).toBe('Hello guests');
|
||||
});
|
||||
|
||||
// #1296 — express-validator runs isInt/isIn/isBoolean element-wise on
|
||||
// arrays, so a single-element array satisfies its field validator and
|
||||
// survives into `updates`, which is spread into .update() with no column
|
||||
// allow-list. That put an array into a scalar column (a PG insert error),
|
||||
// and formatBoolean([false]) read as true. Guarded for every field, not
|
||||
// just the ones that prompted it.
|
||||
it.each([
|
||||
['image_quality', [72]],
|
||||
['protection_level', ['basic']],
|
||||
['use_canvas_rendering', [false]],
|
||||
// Not a protection field: the guard is not scoped to that block.
|
||||
['event_name', ['Arrayed']],
|
||||
['allow_downloads', [false]],
|
||||
])('400s on an array value for %s', async (field, value) => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Unchanged' });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`))
|
||||
.send({ [field]: value });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(field);
|
||||
// And nothing was written.
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('Unchanged');
|
||||
});
|
||||
|
||||
it('still accepts customer_account_ids, the one field that is an array', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Keep' });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
event_name: 'Renamed',
|
||||
customer_account_ids: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('404s when updating a missing event', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/events/999999')).send({
|
||||
event_name: 'Ghost',
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -92,6 +94,35 @@ describe('sanitizeCampaignBody', () => {
|
||||
expect(out).not.toContain('http://evil.example');
|
||||
});
|
||||
|
||||
it('blocks a tracking url() hidden behind " entities', () => {
|
||||
// sanitize-html writes `"` inside an attribute as `"`, so the CSS
|
||||
// scanner and the recipient's browser disagreed about where strings
|
||||
// start: the browser decodes first and reads the apostrophe as ordinary
|
||||
// text inside a real string, then fetches the background — while the
|
||||
// scanner saw the apostrophe open a string and skipped past the url().
|
||||
const out = sanitizeCampaignBody(
|
||||
`<p style="font-family:"don't";background:url(https://evil.example/p.gif)">hi</p>`
|
||||
);
|
||||
expect(out).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('blocks a tracking url() hidden behind an escaped quote', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
`<p style="--m:\\';background:url(https://evil.example/p.gif)">hi</p>`
|
||||
);
|
||||
expect(out).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('keeps a legitimate quoted font stack, re-encoded for the attribute', () => {
|
||||
const out = sanitizeCampaignBody(
|
||||
`<p style="color:red;font-family:"Helvetica Neue",sans-serif">hi</p>`
|
||||
);
|
||||
expect(out).toContain('color:red');
|
||||
expect(out).toContain('Helvetica Neue');
|
||||
// Re-encoded, so the attribute stays well formed rather than being cut short.
|
||||
expect(out).not.toMatch(/style="[^"]*"[^>]*"/);
|
||||
});
|
||||
|
||||
it('strips expression() out of an inline style', () => {
|
||||
const out = sanitizeCampaignBody('<p style="width:expression(alert(1))">hi</p>');
|
||||
expect(out).not.toContain('expression(');
|
||||
@@ -159,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,6 +221,92 @@ describe('sanitizeCSS', () => {
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a C0 control character', '\u0001'],
|
||||
['a NUL byte', '\u0000'],
|
||||
['a DEL byte', '\u007F'],
|
||||
// Newlines are control characters for this strip, so the bypass did not
|
||||
// need an exotic byte — ordinary-looking wrapped CSS was enough.
|
||||
['a newline', '\n'],
|
||||
])('blocks a url() that only becomes one after %s is removed', (_label, ch) => {
|
||||
// Same token-joining hazard as the HTML-comment case above: the control
|
||||
// strip used to run AFTER the URL scan, so `u\u0001rl(...)` was scanned
|
||||
// as clean and then joined into a live remote request, with no warning.
|
||||
const { sanitized, warnings } = sanitizeCSS(
|
||||
`.a{background:u${ch}rl(https://evil.example/p.gif)}`
|
||||
);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
expect(warnings.join(' ')).toContain('external URL');
|
||||
});
|
||||
|
||||
it('blocks a url() hidden behind an escaped quote outside a string', () => {
|
||||
// `\'` is an escaped identifier character, not a string opener. The
|
||||
// scanner used to step onto the apostrophe, enter string mode, and copy
|
||||
// the rest of the stylesheet — url() included — unexamined.
|
||||
const { sanitized } = sanitizeCSS(
|
||||
".hero{--marker:\\';background:url(https://evil.example/p.gif)}"
|
||||
);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a leading escape', '.a{background:\\75rl(https://evil.example/p.gif)}'],
|
||||
['an escape mid-identifier', '.a{background:u\\72l(https://evil.example/p.gif)}'],
|
||||
])('blocks a url() spelled with %s', (_label, css) => {
|
||||
// `\75` is the CSS escape for `u`, so a browser reads `\75rl(` as
|
||||
// url(). The escape-outside-a-string handling has to run AFTER the
|
||||
// identifier check, or it eats the escape and hides the token.
|
||||
const { sanitized } = sanitizeCSS(css);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('blocks a url() the TAG strip would have un-quoted', () => {
|
||||
// `<[^>]*>` deletes the span it matches, and `<">` takes a quote with it.
|
||||
// Running that after the URL scan meant the scanner saw the url() safely
|
||||
// inside a string and this pass then removed the quotes that made it so.
|
||||
// URL validation has to be the last thing that looks at the text.
|
||||
const { sanitized } = sanitizeCSS(
|
||||
'--x:x<">;background:url(https://evil.example/p.gif);--y:x<">'
|
||||
);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('does not treat NBSP as CSS whitespace inside url()', () => {
|
||||
// JS `\s` matches U+00A0; CSS whitespace does not. Skipping it let the
|
||||
// scanner read the following quote as a legitimate data: URI and swallow
|
||||
// a remote url() inside the "string", while a browser sees an unquoted
|
||||
// url-token ending at the first `)` and fetches the remote background.
|
||||
const NBSP = '\u00a0';
|
||||
const { sanitized } = sanitizeCSS(
|
||||
`.a{background:url(${NBSP}"data:image/png);background:url(https://evil.example/p.gif);--x:");}`
|
||||
);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('still allows ordinary CSS whitespace around a data: URI', () => {
|
||||
const spaced = sanitizeCSS('.a{background:url( "data:image/png;base64,iVBORw0KGgo=" )}');
|
||||
expect(spaced.sanitized).toContain('data:image/png');
|
||||
const tabbed = sanitizeCSS('.a{background:url(\t"data:image/png;base64,iVBORw0KGgo=")}');
|
||||
expect(tabbed.sanitized).toContain('data:image/png');
|
||||
});
|
||||
|
||||
it('does not let an unterminated quote hide everything after it', () => {
|
||||
// An unclosed quote is a parse error. Trusting it meant one stray
|
||||
// apostrophe disabled scanning for the remainder of the stylesheet, so
|
||||
// the safe reading is to treat it as an ordinary character and continue.
|
||||
const { sanitized } = sanitizeCSS(
|
||||
"p{font-family:'don't;background:url(https://evil.example/p.gif)}"
|
||||
);
|
||||
expect(asParsed(sanitized)).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('still keeps legitimate quoted values and data: images intact', () => {
|
||||
const font = sanitizeCSS('p{font-family:"Helvetica Neue",sans-serif;color:red}');
|
||||
expect(font.sanitized).toContain('"Helvetica Neue"');
|
||||
const data = sanitizeCSS(".a{background:url('data:image/png;base64,iVBORw0KGgo=')}");
|
||||
expect(data.sanitized).toContain('data:image/png');
|
||||
});
|
||||
|
||||
it('still blocks the other forbidden patterns', () => {
|
||||
const { sanitized } = sanitizeCSS(
|
||||
'@import url("https://x/e.css"); .a{width:expression(alert(1));behavior:url(e.htc)}'
|
||||
|
||||
Reference in New Issue
Block a user