Merge remote-tracking branch 'origin/main' into codex/1110-usage-coverage

This commit is contained in:
Paul Nothaft
2026-09-05 23:59:54 +02:00
55 changed files with 1320 additions and 519 deletions
@@ -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 &quot; entities', () => {
// sanitize-html writes `"` inside an attribute as `&quot;`, 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:&quot;don't&quot;;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:&quot;Helvetica Neue&quot;,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)}'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.123.0-beta.0",
"version": "3.124.1-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
+46 -2
View File
@@ -30,7 +30,8 @@ const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults,
getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
/**
* `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert
@@ -228,6 +229,12 @@ module.exports = (router) => {
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('enable_devtools_protection').optional().isBoolean(),
// Image security. PUT /:id has validated these all along; create
// accepted none of them, so a value sent here used to be dropped on the
// floor and the column default applied instead (#1296).
body('protection_level').optional().not().isArray().isIn(['basic', 'standard', 'enhanced', 'maximum']),
body('use_canvas_rendering').optional().not().isArray().isBoolean().toBoolean(),
body('image_quality').optional().not().isArray().isInt({ min: 1, max: 100 }).toInt(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
// #328 follow-up: per-event opt-in for presigned-URL "Download All".
@@ -544,6 +551,14 @@ module.exports = (router) => {
// the request explicitly overrides it (#317 — admin disabled it globally
// but new events still got it ON because the column default is true).
const protectionDefaults = await getDownloadProtectionDefaults();
// #1296 — the other four Image-security settings, which were written,
// rendered as controls, and read by nothing. Same inheritance rule as
// the devtools setting below. Creation-time only; see
// getImageSecurityDefaults for why existing events are left alone.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
const effectiveEnableDevtoolsProtection =
enableDevtoolsProtectionInput !== undefined
? enableDevtoolsProtectionInput
@@ -617,6 +632,9 @@ module.exports = (router) => {
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection),
// Request value, else the global default, else the column default —
// a key absent here is one the database fills in (#1296).
...imageSecurityColumns,
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
@@ -1400,6 +1418,14 @@ module.exports = (router) => {
allow_downloads: source.allow_downloads,
disable_right_click: source.disable_right_click,
enable_devtools_protection: source.enable_devtools_protection,
// A duplicate inherits the source's protection settings, NOT the
// current global defaults — copying the gallery is the whole point.
// These four sat next to enable_devtools_protection and were simply
// missed, so duplicating a 'maximum' event produced a 'standard' one
// (#1296).
protection_level: source.protection_level,
image_quality: source.image_quality,
use_canvas_rendering: source.use_canvas_rendering,
watermark_downloads: source.watermark_downloads,
watermark_text: source.watermark_text,
allow_presigned_download: source.allow_presigned_download,
@@ -1569,7 +1595,6 @@ module.exports = (router) => {
body('use_canvas_rendering').optional().isBoolean(),
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value) => {
if (value === undefined || value === null || value === '') {
return true;
@@ -1630,6 +1655,25 @@ module.exports = (router) => {
const { id } = req.params;
const updates = { ...req.body };
// express-validator applies isInt/isIn/isBoolean element-wise to
// arrays, so `image_quality: [72]` satisfies its validator and stays
// an array. This handler spreads req.body into .update() with no
// column allow-list, so such a value reaches a scalar column: a PG
// insert error, and `[false]` coerced to true by formatBoolean.
//
// Guarded here rather than per field because it applies to all 44
// validated fields, not to a chosen few. `customer_account_ids` is the
// only field that is legitimately an array, and it is deleted from
// `updates` below before the write (#1296).
const ARRAY_VALUED_FIELDS = new Set(['customer_account_ids']);
const arrayValued = Object.keys(updates)
.filter((key) => Array.isArray(updates[key]) && !ARRAY_VALUED_FIELDS.has(key));
if (arrayValued.length > 0) {
return res.status(400).json({
error: `Array values are not accepted for: ${arrayValued.join(', ')}`,
});
}
// Strip identity/provenance/secret columns from the mass-assigned
// body (GHSA-3rqx). The handler spreads req.body straight into the
// events UPDATE, so without this an events.edit holder could rewrite
+172 -4
View File
@@ -73,14 +73,37 @@ const getEventFieldRequirements = async () => {
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
/**
* Decode an app_settings value into the JS value it represents.
*
* setting_value is JSON text on SQLite and may already be decoded by the
* driver on a PG json column, so one parse does not normalise both. On top
* of that, the Image Security tab used to PUT back values it had read
* undecoded, wrapping another layer of quoting around each one on every
* save — the GET handler decodes now, but installs carry however many
* layers they accumulated before that.
*
* Every reader of app_settings has to agree about this, or the admin UI
* shows one thing while event creation does another.
*
* Terminates: each parse of a string is strictly shorter than its input.
*/
const decodeSettingValue = (raw) => {
let value = raw;
while (typeof value === 'string') {
let parsed;
try { parsed = JSON.parse(value); } catch { break; }
if (parsed === value) break;
value = parsed;
}
return value;
};
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
let value = setting.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
const value = decodeSettingValue(setting.setting_value);
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
@@ -95,6 +118,148 @@ const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
/**
* The rest of Settings → Image security, as creation defaults (#1296).
*
* Four settings in that panel were written, reloaded and rendered as
* controls, and read by nothing:
*
* default_protection_level → events.protection_level
* default_image_quality → events.image_quality
* enable_canvas_rendering → events.use_canvas_rendering
*
* Each maps onto a column migration 038 already created, and each is
* labelled "… by default", so applying them at creation is what the panel
* has always claimed to do. `enable_devtools_protection` above is the only
* one of the five that was ever wired.
*
* Creation-time only, deliberately. Applying them to EXISTING events would
* silently change live galleries on upgrade — an install with
* enable_canvas_rendering already on would switch every grid to canvas
* rendering, which is memory-expensive at scale and is the profile under
* investigation in #1287. New events only; existing rows untouched.
*
* Any value that is missing or malformed comes back undefined so the caller
* falls through to the column default, exactly as before this existed.
*/
const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum'];
// parseInt would rescue malformed settings instead of rejecting them:
// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72.
// That matters because the settings PUT stores whatever JSON it is handed
// without validating the value (adminImageSecurity.js writes
// JSON.stringify(value) for any allow-listed key), so those shapes really
// can be sitting in app_settings. Accept only a genuine integer, or a
// string that is exactly one.
const toInteger = (value) => {
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined;
if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim());
return undefined;
};
const getImageSecurityDefaults = async (trx = null) => {
const defaults = {};
try {
// Accepts a transaction the way getAppSetting does. It matters on
// sqlite3, whose pool holds a single connection: a caller already inside
// db.transaction() that read through the global `db` would block on the
// connection its own transaction holds until the acquire timeout, and
// the catch below would then quietly swallow it and drop the defaults.
const query = trx || db;
const rows = await query('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_canvas_rendering',
])
.select('setting_key', 'setting_value');
// app_settings holds JSON text on SQLite, while a PG json column comes
// back already decoded — so one parse is not enough to normalise both.
// Worse, GET /api/admin/image-security/settings returns setting_value
// without decoding it and the settings tab PUTs the whole fetched object
// straight back through JSON.stringify, so opening the tab and saving
// re-encodes every value it read as text. After one such round trip
// `true` is stored as "\"true\"" and a single parse yields the string
// 'true', which the type checks below reject — the settings would go
// quietly dead again, which is the bug this whole change exists to fix.
// The GET handler now decodes, so this stops accumulating — but installs
// that already stacked N layers have to keep working, and N is however
// many times someone opened that tab. So unwrap until it stops being a
// JSON string rather than to a fixed depth; this terminates because each
// parse of a string is strictly shorter than its input.
const read = (key) => {
const row = rows.find((r) => r.setting_key === key);
if (!row) return undefined;
return decodeSettingValue(row.setting_value);
};
const level = read('default_protection_level');
if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) {
defaults.protection_level = level;
}
// The column is an integer percentage; anything outside 1..100 is a
// misconfiguration and falls through rather than being clamped into
// something the operator did not choose.
const quality = toInteger(read('default_image_quality'));
if (quality !== undefined && quality >= 1 && quality <= 100) {
defaults.image_quality = quality;
}
const canvas = read('enable_canvas_rendering');
if (typeof canvas === 'boolean') {
defaults.use_canvas_rendering = canvas;
}
} catch (error) {
// A settings read must never block event creation; the column defaults
// are a correct fallback.
logger.error('Failed to read image-security defaults', { error: error.message });
}
return defaults;
};
/**
* Build the image-security columns for a NEW event: an explicit request
* value wins, then the global default, then the column default (the key is
* omitted entirely so the database supplies it).
*
* Shared by the admin create route and POST /api/v1/events so the configured
* security level cannot depend on which entry point created the gallery —
* the same split that made #592 (devtools) a separate bug from #317.
*
* `body` values are already validated by the route's express-validator
* chain; `defaults` come from getImageSecurityDefaults(), which validates
* them itself.
*/
const resolveImageSecurityColumns = (body = {}, defaults = {}) => {
const { formatBoolean } = require('../../utils/dbCompat');
const columns = {};
// express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a
// single-element array like `image_quality: [72]` passes the route's chain
// and arrives here still an array. The routes reject those with
// .not().isArray(); this guard means any future caller cannot write one
// into a scalar column (a PG insert error, or `[false]` coerced to true).
const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v);
const pick = (key) => {
const fromBody = scalar(body[key]);
return fromBody !== undefined ? fromBody : defaults[key];
};
const level = pick('protection_level');
if (level !== undefined) columns.protection_level = level;
const quality = pick('image_quality');
if (quality !== undefined) columns.image_quality = quality;
const canvas = pick('use_canvas_rendering');
if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas);
return columns;
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
@@ -527,7 +692,10 @@ module.exports = {
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
decodeSettingValue,
getDownloadProtectionDefaults,
getImageSecurityDefaults,
resolveImageSecurityColumns,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
+10 -5
View File
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { decodeSettingValue } = require('./adminEvents/helpers');
const router = express.Router();
@@ -22,7 +23,6 @@ router.get('/settings', adminAuth, requirePermission(['settings.view', 'image_se
'max_image_requests_per_hour',
'suspicious_activity_threshold',
'enable_canvas_rendering',
'default_fragmentation_level',
'security_monitoring_enabled',
'block_suspicious_ips',
'log_security_events_to_db',
@@ -32,9 +32,15 @@ router.get('/settings', adminAuth, requirePermission(['settings.view', 'image_se
const config = {};
settings.forEach(setting => {
// PostgreSQL JSON columns are already parsed by the driver
// Just use the value directly - no need to JSON.parse
config[setting.setting_key] = setting.setting_value;
// setting_value is JSON text on SQLite, and already decoded by the
// driver on a PG json column — so returning it raw shipped strings
// like "true" to a tab that types the field as boolean. Worse, the
// tab PUTs this whole object straight back through JSON.stringify,
// so every save wrapped another layer of quoting around values nobody
// edited, until consumers could no longer read them (#1296). Decode
// here so a round trip is idempotent. This terminates: each parse of
// a string is strictly shorter than its input.
config[setting.setting_key] = decodeSettingValue(setting.setting_value);
});
res.json(config);
@@ -61,7 +67,6 @@ router.put('/settings', adminAuth, requirePermission('image_security.manage'), a
'max_image_requests_per_hour',
'suspicious_activity_threshold',
'enable_canvas_rendering',
'default_fragmentation_level',
'security_monitoring_enabled',
'block_suspicious_ips',
'log_security_events_to_db',
-1
View File
@@ -1227,7 +1227,6 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, asy
protection_level: req.event.protection_level || 'standard',
image_quality: req.event.image_quality || 85,
use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false),
fragmentation_level: req.event.fragmentation_level || 3,
overlay_protection: parseBooleanInput(req.event.overlay_protection, true)
};
+1 -17
View File
@@ -117,8 +117,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
const protectionSettings = {
protectionLevel: eventProtectionLevel,
quality: req.event.image_quality || 85,
addFingerprint: req.event.add_fingerprint !== false,
fragmentImage: eventProtectionLevel === 'maximum'
addFingerprint: req.event.add_fingerprint !== false
};
// Resolve photo location through the storage backend (managed) or local
@@ -151,21 +150,6 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery
? await withLocalCopy(storageKey, runProcessing)
: await runProcessing(resolvePhotoFilePath(req.event, photo));
if (processedImage.type === 'fragmented') {
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
}
finalImage = processedImage;
}
+1 -61
View File
@@ -120,8 +120,6 @@ router.get('/:slug/secure/:photoId/:token',
tokenLength: token?.length,
hasAuthHeader: Boolean(req.headers.authorization),
});
const { fragment } = req.query;
// Verify secure token
const tokenValidation = secureImageService.verifySecureToken(
token,
@@ -212,8 +210,7 @@ router.get('/:slug/secure/:photoId/:token',
const protectionSettings = {
protectionLevel: event.protection_level || 'standard',
quality: event.image_quality || 85,
addFingerprint: event.add_fingerprint !== false,
fragmentImage: event.use_canvas_rendering === true && fragment !== undefined
addFingerprint: event.add_fingerprint !== false
};
let processedImage;
@@ -232,11 +229,6 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo file not found' });
}
// Handle fragmented images
if (processedImage.type === 'fragmented') {
return await handleFragmentedImage(req, res, processedImage, fragment);
}
// Log successful access
await secureImageService.logImageAccess(
photoId,
@@ -267,58 +259,6 @@ router.get('/:slug/secure/:photoId/:token',
}
);
/**
* Handle fragmented image delivery
*/
async function handleFragmentedImage(req, res, fragmentedImage, fragmentIndex) {
const { photoId } = req.params;
try {
if (fragmentIndex === undefined) {
// Return fragment metadata
res.json({
type: 'fragmented',
fragments: fragmentedImage.fragments.length,
dimensions: fragmentedImage.originalDimensions,
fragmentDimensions: fragmentedImage.fragmentDimensions
});
return;
}
const index = parseInt(fragmentIndex);
if (isNaN(index) || index < 0 || index >= fragmentedImage.fragments.length) {
return res.status(400).json({ error: 'Invalid fragment index' });
}
const fragment = fragmentedImage.fragments[index];
// Log fragment access
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
`fragment_${index}`
);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': fragment.buffer.length,
'X-Fragment-Index': index,
'X-Fragment-Position': JSON.stringify(fragment.position)
});
res.send(fragment.buffer);
} catch (error) {
logger.error('Error serving image fragment', {
error: error.message,
fragmentIndex,
photoId
});
res.status(500).json({ error: 'Failed to serve image fragment' });
}
}
/**
* Download protected image with watermark
*/
@@ -126,6 +126,7 @@ const BASE_BODY = {
const baseSettingsChains = () => [
buildChain({ firstResult: null }), // feedback default
buildChain({ firstResult: null }), // devtools default
buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296)
buildChain({ selectResult: [] }), // branding whereIn → empty rows
];
@@ -169,19 +170,21 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// feedback_enabled provided → feedback probe SKIPPED. Sequence:
// 1. devtools probe
// 2. branding probe (whereIn → select)
// 3. slug probe
// 4. events insert
// 5. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 6. event_feedback_settings insert
// 2. image-security probe (whereIn → select, #1296)
// 3. branding probe (whereIn → select)
// 4. slug probe
// 5. events insert
// 6. feedback sub-toggle defaults probe (whereIn → select, #1044)
// 7. event_feedback_settings insert
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
devtoolsChain, brandingChain, slugChain, insertChain,
devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
);
@@ -190,7 +193,7 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
expect(db).toHaveBeenNthCalledWith(6, 'event_feedback_settings');
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
@@ -216,20 +219,21 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// Feedback probe returns serialized "true" → fallback kicks in and
// the feedback insert runs. Sequence: feedback probe, devtools probe,
// branding probe, slug, insert, sub-toggle defaults probe (#1044),
// feedback insert (7 calls total).
// image-security probe (#1296), branding probe, slug, insert, sub-toggle
// defaults probe (#1044), feedback insert (8 calls total).
const feedbackProbe = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const devtoolsChain = buildChain({ firstResult: null });
const imageSecurityChain = buildChain({ selectResult: [] });
const brandingChain = buildChain({ selectResult: [] });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackDefaultsChain = buildChain({ selectResult: [] });
const feedbackInsertChain = buildChain();
db.__setImplementations(
feedbackProbe, devtoolsChain, brandingChain, slugChain, insertChain,
feedbackDefaultsChain, feedbackInsertChain,
feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain,
insertChain, feedbackDefaultsChain, feedbackInsertChain,
);
await request(buildApp())
@@ -237,7 +241,7 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings');
expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
@@ -251,9 +255,9 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send(BASE_BODY)
.expect(201);
// 5 db() calls: feedback + devtools + branding probes, slug, insert.
// event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(5);
// 6 db() calls: feedback + devtools + image-security + branding probes,
// slug, insert. event_feedback_settings is never touched.
expect(db).toHaveBeenCalledTimes(6);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
+23 -4
View File
@@ -37,6 +37,7 @@ const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers');
const { isValidEventType } = require('../../services/eventTypeService');
const { replacePhoto } = require('../../services/photoReplacementService');
const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings');
@@ -134,6 +135,9 @@ const photoUpload = async (req, res, next) => {
* color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." }
* feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." }
* enable_devtools_protection: { type: boolean, nullable: true, description: "Block right-click / devtools shortcuts in the gallery. When omitted, falls back to the global enable_devtools_protection setting." }
* protection_level: { type: string, nullable: true, enum: [basic, standard, enhanced, maximum], description: "Image protection level. When omitted, falls back to the global default_protection_level setting." }
* use_canvas_rendering: { type: boolean, nullable: true, description: "Render gallery images to a canvas instead of an img tag. When omitted, falls back to the global enable_canvas_rendering setting." }
* image_quality: { type: integer, minimum: 1, maximum: 100, nullable: true, description: "Served image quality percentage. When omitted, falls back to the global default_image_quality setting." }
* hero_logo_visible: { type: boolean, nullable: true, description: "Show event logo in the hero block. When omitted, falls back to the global branding_logo_display_hero setting." }
* hero_logo_size: { type: string, nullable: true, enum: [small, medium, large, xlarge], description: "Hero logo size. When omitted, falls back to the global branding_logo_size setting." }
* hero_logo_position: { type: string, nullable: true, enum: [top, center, bottom], description: "Hero logo position. Defaults to 'top' (not settings-backed — see migration 084)." }
@@ -179,6 +183,9 @@ router.post(
body('color_theme').optional({ nullable: true }).isString().trim(),
body('feedback_enabled').optional().isBoolean(),
body('enable_devtools_protection').optional().isBoolean(),
body('protection_level').optional().not().isArray().isIn(['basic', 'standard', 'enhanced', 'maximum']),
body('use_canvas_rendering').optional().not().isArray().isBoolean().toBoolean(),
body('image_quality').optional().not().isArray().isInt({ min: 1, max: 100 }).toInt(),
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
@@ -227,14 +234,24 @@ router.post(
if (devtoolsInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
} catch { /* keep true */ }
// Shared decoder: a legacy row can carry several layers of JSON
// quoting, and a single parse would leave the string 'false' here,
// reject it, and quietly enable protection the operator disabled.
const parsed = decodeSettingValue(setting.setting_value);
if (typeof parsed === 'boolean') devtoolsFallback = parsed;
}
}
const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback);
// #1296 — same shape again, for the four Image Security settings that
// were stored and applied nowhere. Shared with the admin create route
// so a gallery's security level does not depend on which endpoint made
// it; #592 above is the bug this would otherwise repeat.
const imageSecurityColumns = resolveImageSecurityColumns(
req.body,
await getImageSecurityDefaults(),
);
// Same shape as the feedback / devtools fallbacks: honour the global
// event_default_require_password toggle (#317). Without this an admin
// who disabled "require password by default" globally still got
@@ -324,6 +341,8 @@ router.post(
// Issue #592 — write the resolved devtools setting (input value
// or global fallback) so the column default doesn't shadow it.
enable_devtools_protection: formatBoolean(enable_devtools_protection),
// Request value, else the global default, else the column default.
...imageSecurityColumns,
// Branding inheritance — resolved value from body or app_settings.
hero_logo_visible: formatBoolean(hero_logo_visible),
hero_logo_size,
@@ -229,6 +229,8 @@ async function convertToEvent(contractId, adminId) {
|| (await resolveDefaultEventType());
const eventCols = await db('events').columnInfo();
const { getImageSecurityDefaults, resolveImageSecurityColumns } = require('../../routes/adminEvents/helpers');
const imageSecurityColumns = resolveImageSecurityColumns({}, await getImageSecurityDefaults());
const candidate = {
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
// Prefer the contract's event_name snapshot (set on the contract
@@ -255,6 +257,11 @@ async function convertToEvent(contractId, adminId) {
quote_id: null,
created_at: new Date(),
updated_at: new Date(),
// #1296 — a signed standalone contract converts straight to a gallery
// here, without going through quoteService, so the global Image Security
// defaults have to be applied on this path too. Not inside a transaction,
// so the global db read is fine.
...imageSecurityColumns,
};
const eventRow = {};
for (const [k, v] of Object.entries(candidate)) {
+78 -25
View File
@@ -141,35 +141,81 @@ function sanitizeCampaignBody(html) {
},
})
// sanitize-html keeps the style ATTRIBUTE contents verbatim. Clean each.
.replace(/style="([^"]*)"/gi, (match, css) => {
const { sanitized } = sanitizeCSS(css);
const cleaned = stripRemoteCssUrls(sanitized);
return cleaned ? `style="${cleaned.replace(/"/g, '')}"` : '';
});
// 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.
//
// Entities are decoded BEFORE the CSS is scanned, and re-encoded after.
// sanitize-html emits `"` inside an attribute as `&quot;`, so the scanner
// and the recipient's browser otherwise disagree about where CSS strings
// begin: in `style="font-family:&quot;don't&quot;;background:url(...)"`
// the browser decodes first and reads the apostrophe as ordinary text
// 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_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)}"` : '';
}
/**
* Remove every `url(...)` that is not an inline data: image.
* Re-check inline CSS AFTER template substitution.
*
* 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:
* 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:
*
* sanitizeCSS('.a{background:url(https://x/p.gif)}').sanitized
* → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}'
* style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)"
*
* 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.
* 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 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'));
function sanitizeInlineStylesAfterSubstitution(html) {
if (!html) return html;
return String(html).replace(STYLE_ATTRIBUTE, sanitizeStyleAttribute);
}
/**
* Decode the HTML entities sanitize-html emits inside attribute values, so
* CSS is scanned in the form the recipient's parser will see. One pass, so
* `&amp;quot;` decodes to `&quot;` and not to `"`.
*/
function decodeHtmlEntities(value) {
return String(value).replace(
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|(quot|apos|amp|lt|gt));/g,
(whole, dec, hex, name) => {
if (dec !== undefined) {
const code = Number(dec);
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
}
if (hex !== undefined) {
const code = parseInt(hex, 16);
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
}
return { quot: '"', apos: '\'', amp: '&', lt: '<', gt: '>' }[name];
}
);
}
/** Re-encode a sanitized value so it is safe inside a double-quoted attribute. */
function encodeForAttribute(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
@@ -178,7 +224,8 @@ function stripRemoteCssUrls(css) {
* `javascript:` and every `url()` that is not a `data:` image.
*
* That is STRICTER than the issue's "https: images only" note — the shared
* sanitizer allows no remote `url()` at all. Kept as-is rather than loosened:
* sanitizer allows no remote `url()` at all, and since #1290 it enforces
* that by lexing rather than by pattern-matching. Kept as-is rather than loosened:
* a remote CSS url() in mail is a tracking pixel by another name, and a
* campaign's images belong in `<img>` tags where the scheme filter sees them.
*
@@ -187,7 +234,7 @@ function stripRemoteCssUrls(css) {
function sanitizeCampaignCss(css) {
if (!css) return { css: '', warnings: [] };
const { sanitized, warnings } = sanitizeCSS(String(css));
return { css: stripRemoteCssUrls(sanitized), warnings };
return { css: sanitized, warnings };
}
// ---------------------------------------------------------------------------
@@ -310,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);
@@ -922,6 +974,7 @@ async function sendTest(campaignId, toEmail, adminId) {
module.exports = {
sanitizeCampaignBody,
sanitizeInlineStylesAfterSubstitution,
sanitizeCampaignCss,
unsubscribeToken,
verifyUnsubscribeToken,
+7
View File
@@ -1683,6 +1683,8 @@ async function convertToEvent(quoteId, adminId, options = {}) {
// ask the DB which columns exist and only keep the matching pairs
// — bullet-proof against schema drift in either direction.
const eventCols = await trx('events').columnInfo();
const { getImageSecurityDefaults, resolveImageSecurityColumns } = require('../routes/adminEvents/helpers');
const imageSecurityColumns = resolveImageSecurityColumns({}, await getImageSecurityDefaults(trx));
const candidate = {
slug: `quote-${quote.quote_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
event_name: quote.event_name || `Event ${quote.quote_number}`,
@@ -1705,6 +1707,11 @@ async function convertToEvent(quoteId, adminId, options = {}) {
quote_id: quote.id,
created_at: new Date(),
updated_at: new Date(),
// #1296 — a converted quote produces a real gallery, so the global
// Image Security defaults have to reach it too. Required lazily: this
// is a service reaching into a route helper, and the lazy form keeps
// the module graph acyclic the way the storage require below does.
...imageSecurityColumns,
};
const eventRow = {};
for (const [k, v] of Object.entries(candidate)) {
+3 -56
View File
@@ -175,8 +175,7 @@ class SecureImageService {
quality = 85,
maxWidth = 1920,
maxHeight = 1080,
addFingerprint = true,
fragmentImage = false
addFingerprint = true
} = options;
try {
@@ -187,7 +186,7 @@ class SecureImageService {
// For standard protection without fingerprinting, return original file
// This avoids unnecessary recompression when no protection features are needed
if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) {
if (protectionLevel === 'standard' && !addFingerprint) {
return await fs.readFile(imagePath);
}
@@ -268,14 +267,7 @@ class SecureImageService {
});
}
const buffer = await image.toBuffer();
// Fragment image if requested (for canvas reconstruction)
if (fragmentImage && protectionLevel === 'maximum') {
return await this.fragmentImageBuffer(buffer, metadata);
}
return buffer;
return await image.toBuffer();
} catch (error) {
logger.error('Error processing protected image:', error);
// Return original on error
@@ -283,51 +275,6 @@ class SecureImageService {
}
}
/**
* Fragment image into multiple pieces for canvas reconstruction
*/
async fragmentImageBuffer(buffer, metadata) {
const { width, height } = metadata;
const fragments = [];
// Create 3x3 grid of fragments
const cols = 3;
const rows = 3;
const fragmentWidth = Math.floor(width / cols);
const fragmentHeight = Math.floor(height / rows);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const left = col * fragmentWidth;
const top = row * fragmentHeight;
const fragment = await sharp(buffer)
.extract({
left,
top,
width: fragmentWidth,
height: fragmentHeight
})
.toBuffer();
fragments.push({
index: row * cols + col,
row,
col,
buffer: fragment,
position: { left, top, width: fragmentWidth, height: fragmentHeight }
});
}
}
return {
type: 'fragmented',
fragments,
originalDimensions: { width, height },
fragmentDimensions: { width: fragmentWidth, height: fragmentHeight, cols, rows }
};
}
/**
* Log image access for security monitoring
*/
+71 -17
View File
@@ -147,11 +147,27 @@ function stripDisallowedUrls(css) {
if (input[i] === '"' || input[i] === '\'') {
const quote = input[i];
let j = i + 1;
let closed = false;
while (j < input.length) {
if (input[j] === '\\') { j += 2; continue; }
if (input[j] === quote) { j += 1; break; }
// A newline ends a string in CSS (it produces a bad-string token), so
// an unclosed quote must not run past the end of its own line.
if (input[j] === '\n' || input[j] === '\r' || input[j] === '\f') break;
if (input[j] === quote) { j += 1; closed = true; break; }
j += 1;
}
// An UNTERMINATED quote is a parse error, and trusting it is how a
// stray apostrophe hid everything after it: `font-family:&quot;don't`
// opened a string that swallowed the url() following it, while the
// recipient's browser — which decodes the entity first — saw the
// apostrophe safely inside a real string and made the request. Failing
// closed here means emitting the quote as an ordinary character and
// carrying on scanning, so a later url() is still examined.
if (!closed) {
out += input[i];
i += 1;
continue;
}
out += input.slice(i, Math.min(j, input.length));
i = Math.min(j, input.length);
continue;
@@ -161,7 +177,7 @@ function stripDisallowedUrls(css) {
const ident = readIdentifier(input, i);
if (ident.end > i && decodeCssEscapes(ident.raw).toLowerCase() === 'url') {
let j = ident.end;
while (j < input.length && /\s/.test(input[j])) j += 1;
while (j < input.length && CSS_WS.test(input[j])) j += 1;
if (input[j] === '(') {
const token = readUrlToken(input, j);
if (token) {
@@ -185,6 +201,19 @@ function stripDisallowedUrls(css) {
continue;
}
// --- escape that does NOT begin an identifier -------------------------
// Ordered AFTER readIdentifier deliberately. `\75` is the escape for
// `u`, so `\75rl(...)` is url() to a browser — consuming the escape
// first would hide it from the check above, which is a bypass this
// branch introduced when it ran earlier. What is left for it is the
// `\'` case: an escaped quote that must not be read as opening a
// string, since that swallowed the rest of the stylesheet unscanned.
if (input[i] === '\\' && i + 1 < input.length) {
out += input.slice(i, i + 2);
i += 2;
continue;
}
out += input[i];
i += 1;
}
@@ -209,6 +238,15 @@ function matchEscape(input, start) {
* escaped Tailwind selector) is emitted byte-identical rather than silently
* rewritten to `.w-1/2`, which is a different selector.
*/
// CSS whitespace is exactly space, tab, LF, CR and FF. JavaScript's `\s`
// is NOT the same set — it also matches NBSP and the other Unicode spaces,
// and that difference was a bypass: in `url(\u00a0"data:image/png);...")`
// the scanner skipped the NBSP as whitespace and read the following quote as
// a legitimate quoted data: URI, swallowing a remote url() inside it. A
// browser treats NBSP as an ordinary character, making that an UNQUOTED
// url-token that ends at the first `)` — leaving the remote background live.
const CSS_WS = /[ \t\n\r\f]/;
function readIdentifier(input, start) {
let j = start;
let raw = '';
@@ -228,7 +266,7 @@ function readIdentifier(input, start) {
function readUrlToken(input, openParen) {
let j = openParen + 1;
let target = '';
while (j < input.length && /\s/.test(input[j])) j += 1;
while (j < input.length && CSS_WS.test(input[j])) j += 1;
if (input[j] === '"' || input[j] === '\'') {
// Quoted: the quote closes the value, so ")" inside it is content.
@@ -249,7 +287,7 @@ function readUrlToken(input, openParen) {
}
}
while (j < input.length && /\s/.test(input[j])) j += 1;
while (j < input.length && CSS_WS.test(input[j])) j += 1;
// Unterminated url( — malformed. Leave it alone rather than swallowing the
// remainder of the stylesheet.
if (input[j] !== ')') return null;
@@ -291,12 +329,35 @@ function sanitizeCSS(cssContent) {
// Remove HTML comments that might be used for injection
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
// URLs are scanned AFTER the comment strip, not before. Removing
// `<!--x-->` from `u<!--x-->rl(https://evil.example/p.gif)` JOINS the
// remaining characters into a live `url(...)` — so a scan that ran first
// saw no token, reported the input clean, and the transformation below it
// then produced exactly the request the scan was there to prevent. Any
// pass that can join tokens has to happen before validation, not after.
// Remove control characters BEFORE the URL scan. This is the same
// token-joining hazard as the HTML-comment strip above: dropping the
// \u0001 from `u\u0001rl(https://evil.example/p.gif)` joins the remainder
// into a live `url(...)`, so a scan that ran first saw no token and
// reported the input clean. Newlines are control characters too, which
// made `u\nrl(...)` the same bypass in ordinary-looking CSS.
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
// URLs are scanned AFTER the comment and control-character strips, not
// before. Removing `<!--x-->` from `u<!--x-->rl(https://evil.example/p.gif)`
// JOINS the remaining characters into a live `url(...)` — so a scan that
// ran first saw no token, reported the input clean, and the transformation
// below it then produced exactly the request the scan was there to
// prevent. Any pass that can join tokens has to happen before validation,
// not after.
// Remove any remaining script-like content. This is the LAST pass that can
// move text, and so it must run before the URL scan, not after: it deletes
// the matched span, and a span like `<">` takes a quote with it. That is
// how `--x:x<">;background:url(https://evil.example/p.gif);--y:x<">` shipped
// a live background — the scanner saw the url() safely inside a string, and
// this line then removed the quotes that made it so.
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
// URL validation runs LAST, deliberately. Every pass above rewrites the
// text, and each one that did so after this point has produced a bypass:
// the HTML-comment strip (#1290), the control-character strip, and the tag
// strip immediately above. Validating anything other than the final bytes
// means validating a string that is not the one that gets served.
const urlPass = stripDisallowedUrls(sanitized);
if (urlPass.blocked > 0) {
warnings.push(
@@ -306,13 +367,6 @@ function sanitizeCSS(cssContent) {
sanitized = urlPass.sanitized;
}
// Remove control characters
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
// Remove any remaining script-like content
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
return { sanitized: sanitized.trim(), warnings };
}