fix(contacts): stop edit route from reassigning a QSO's owner or injecting SQL - #251
Merged
Merged
Conversation
…g SQL
`Contact.update` built its UPDATE by interpolating *every* key of the
input object straight into the SQL as a column name (`SET ${key} = $n`),
and `PUT /api/contacts/[id]` passes the raw JSON request body in with no
filtering. Two problems followed:
- Mass assignment: a caller could move one of their own QSOs into another
operator's log by sending `{ "user_id": <someone-else> }` — only
id/created_at/updated_at were excluded, not user_id.
- SQL injection: the key is concatenated, never bound, so a crafted key
(e.g. `"user_id = 1, notes"`) becomes live SQL rather than a bad-column
error.
Fix: route the update through a fixed column allowlist. New
server-imports-free `@/lib/contact-update` exposes `UPDATABLE_CONTACT_COLUMNS`
(every editable `contacts` column except id/user_id/created_at/updated_at)
and a pure `buildContactUpdate()` that emits `col = $n` assignments only
for allowlisted keys, dropping unknown/forbidden keys and `undefined`
values while keeping placeholders in lockstep with their bound values.
Unit-tested directly like `@/lib/contact-search`. The edit form's
legitimate fields are all allowlisted, so normal edits are unchanged; it
was already re-sending user_id unchanged, which is now simply dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Contact.update(behindPUT /api/contacts/[id], the contact edit flow) built itsUPDATEstatement by interpolating every key of the input object directly into the SQL as a column name:The route (
src/app/api/contacts/[id]/route.ts) verifies ownership of the existing row, then passes the raw JSON request body straight intoContact.update. Two problems fell out of that:id/created_at/updated_atwere excluded, notuser_id. A caller could move one of their own QSOs into another operator's log with{ "user_id": <someone-else> }."user_id = 1, notes"becomes live SQL instead of a harmless bad-column error.As a bonus, the old code would also throw a
column ... does not existerror if the contact object ever carried a joined/derived field (e.g.station_callsign), since any stray key was treated as a real column.Solution
Route the update through a fixed column allowlist at the model layer — the single choke point both callers (
PUT /api/contacts/[id]andPOST /api/contacts/populate-location) share.New server-imports-free module
src/lib/contact-update.ts(same pattern assrc/lib/contact-search.ts) exposes:UPDATABLE_CONTACT_COLUMNS— every editablecontactscolumn (derived fromdrizzle/schema.ts) except the identity/ownership/bookkeeping columnsid,user_id,created_at,updated_at.buildContactUpdate(contactData)— a pure function that emitscol = $nassignments and their bound values only for allowlisted keys, dropping unknown/forbidden keys andundefinedvalues while keeping placeholders in lockstep with the values array.nullis kept, so the edit form can still clear a field.Contact.updatenow callsbuildContactUpdateinstead of iterating over arbitrary keys.Backwards compatibility: the edit dialog spreads
...formData(initialized from...contact), so it was already re-sendinguser_idunchanged on every save — that key is now simply dropped, and every field the form legitimately edits (callsign, mode, band, station_id, notes, tx_pwr, grid_locator, QSL flags, …) is in the allowlist. Normal edits behave identically.populate-locationonly sendsgrid_locator/latitude/longitude, all allowlisted.Testing
tests/contact-update.spec.ts(8 cases, pure-function like the search-builder spec) covering: lockstep placeholders,undefinedskipping,nullretained, ownership/identity columns dropped, crafted-injection keys dropped, empty result when nothing updatable, interleaved-forbidden-key numbering, and the allowlist membership sanity check. 8 passed.npm run typecheck— cleannpm run lint— cleannpm run build— succeedsFuture follow-up
PUT /api/stationsroutes take the raw body too; they set explicit columns rather than dynamic keys, so they aren't vulnerable, but a shared allowlist helper there would be tidy.🤖 Generated with Claude Code