diff --git a/backend/__tests__/routes/authzPermissionGaps.test.js b/backend/__tests__/routes/authzPermissionGaps.test.js index 46281d6b..0918590e 100644 --- a/backend/__tests__/routes/authzPermissionGaps.test.js +++ b/backend/__tests__/routes/authzPermissionGaps.test.js @@ -137,11 +137,17 @@ describe('authorization / ownership gaps', () => { // Case-variant keys — SQLite matches columns case-insensitively. Password_Hash: 'case-hijack-hash', Created_By: 88888, + // A case variant of an ORDINARY column must not reach the UPDATE + // either: field-level guards in the handler key on the exact name, + // and on SQLite the variant would still land on the real column. + Event_Name: 'case-variant-name', + Welcome_Message: 'case-variant-welcome', }); expect(res.status).toBe(200); const row = await db('events').where({ id: eventId }).first(); - expect(row.event_name).toBe('After'); // legit field applied + expect(row.event_name).toBe('After'); // legit field applied; Event_Name variant dropped + expect(row.welcome_message).toBeFalsy(); // case variant of an ordinary column dropped expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant) expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched expect(row.share_token).toBe(seedShareToken); // secret untouched diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 58bb4b21..0c494422 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -1730,10 +1730,18 @@ module.exports = (router) => { // Legacy mirrors — rejected explicitly below in favour of customer_*. 'host_name', 'host_email', ]; - // Case-insensitive match: SQLite treats quoted identifiers - // case-insensitively, so a `{ "Password_Hash": ... }` key would - // otherwise survive a case-sensitive delete and still hit the real - // column (codex review). + // Only canonical keys reach the UPDATE. SQLite resolves quoted + // identifiers case-insensitively, so `{ "Event_Name": ... }` lands on + // event_name there while every check in this handler — validators, + // the permission guards on individual fields, the deny-set below — is + // keyed on the exact lowercase name. Every events column and every + // input-only key this handler accepts is lowercase snake_case, so a key + // with any uppercase in it is not something a legitimate client sends; + // it is dropped before anything looks at it. The deny-set keeps its own + // case-folding as belt and braces (GHSA-3rqx). + for (const key of Object.keys(updates)) { + if (key !== key.toLowerCase()) delete updates[key]; + } const denied = new Set(IMMUTABLE_EVENT_COLUMNS.map((c) => c.toLowerCase())); for (const key of Object.keys(updates)) { if (denied.has(key.toLowerCase())) delete updates[key];