fix(events): drop non-canonical keys from the event update before any check runs (#1346)

PUT /admin/events/:id spreads the body into the UPDATE. SQLite resolves
quoted identifiers case-insensitively, so `{ "Event_Name": ... }` lands
on event_name there — while every check in the handler (validators, the
field-level permission guards, the deny-set) keys on the exact lowercase
name. The deny-set already case-folded for its own columns; every other
column was reachable through a spelling variant.

Every events column and every input-only key the handler accepts is
lowercase snake_case, so a key with any uppercase in it is not something
a legitimate client sends. Such keys are now removed before anything
looks at the body. Postgres was unaffected (quoted identifiers are
case-sensitive there; a variant produced a 500 instead).

Surfaced by the Codex review of the folder-watcher change, where a
photos.upload guard on external_watch could be walked around this way.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 22:30:13 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 8cc7d7d14a
commit 810801a9ab
2 changed files with 19 additions and 5 deletions
+12 -4
View File
@@ -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];