fix(security): close the remaining image-security default gaps

Round-two review follow-ups.

Settings survive the tab round trip. GET returns setting_value without
decoding it and ImageSecurityTab PUTs the whole fetched object back
through JSON.stringify, so on SQLite one visit to the tab re-encodes
every value it read. A single parse then yields the string "true", the
type checks reject it, and the defaults go quietly dead — the exact bug
this change exists to fix, returning by a different route. The reader
now unwraps until the value stops being a JSON string, bounded.

Array overrides rejected. express-validator applies isInt/isIn/isBoolean
element-wise, so `image_quality: [72]` passed the chain and arrived
still an array — a PG insert error, and `[false]` coerced to true by
formatBoolean. Both create routes now use .not().isArray(), and the
shared resolver ignores non-scalars for any future caller.

Two more creation paths covered. quoteService.convertToEvent builds its
own events row, so CRM-converted galleries fell back to column defaults.
/:id/duplicate copies fifteen source columns including
enable_devtools_protection but missed these four, so duplicating a
'maximum' gallery produced a 'standard' one — a duplicate now inherits
the source's values, not the current globals, since copying the gallery
is the point.

The PUT /:id chain has the same array weakness. Pre-existing and outside
this fix; left alone deliberately.

Refs #1296
This commit is contained in:
Paul Nothaft
2026-09-05 07:27:18 +02:00
parent ab6c33d9eb
commit 19c518aaa5
5 changed files with 82 additions and 11 deletions
@@ -107,6 +107,39 @@ describe('image-security creation defaults', () => {
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('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('resolveImageSecurityColumns', () => {
it('omits every column when neither the request nor the settings supply one', () => {
expect(resolveImageSecurityColumns({}, {})).toEqual({});