Один рядок коду може зруйнувати всю вашу модель контролю доступу. Якщо ви просто передаєте тіло запиту безпосередньо в оновлення бази даних, ви фактично даєте клієнту ручку, щоб він міг переписати вашу схему. Це масове призначення (mass assignment). Це не якась екзотична помилка чи рідкісний випадок. Це помилка проєктування, яка виникає щоразу, коли API сприймає корисне навантаження (payload) як власну політику оновлення.

await db.users.update(req.params.id, { ...req.body });

Це виглядає чисто. Це економить час на написання коду. Але клієнт контролює ключі. Зловмисник може додати "role": "admin", "accountId": "someone_else" або "credit": 99999" до звичайного оновлення профілю. Ваш шар валідації може перевірити, чи є ці значення рядками чи числами, і сказати, що вони виглядають нормально. Однак валідність — це не авторизація. Користувач може мати законне право власності на цільовий запис. Це не означає, що він має право редагувати кожне поле всередині нього.

Як насправді виглядає масове призначення

Небезпека ховається в зручності. Фреймворки та ORM роблять надзвичайно простим відображення JSON-ключів безпосередньо на колонки бази даних. Коли ви це робите, ви кажете базі даних довіряти клієнту щодо того, що саме має змінитися, а не лише того, як це має змінитися.

Користувач, який оновлює свій профіль, може надіслати валідні дані для displayName та bio, але водночас підкинути role або balance. Якщо ваш контролер просто пересилає об'єкт, база даних запише все. Валідація ловить неправильно сформовані значення. Вона рідко ловить шкідливі ключі. Бізнес-правило, яке стверджує, що «цей користувач має право оновлювати свій профіль», перетворюється на загальний дозвіл для кожної колонки в рядку.

Виправлення полягає не в посиленні валідації. Воно полягає в суворішій архітектурі.

Три брами

Безпечна мутація проходить через три окремі перевірки, перш ніж торкнутися сховища.

Дозволені поля (Allowlist)

Почніть із визначення того, на які саме ключі ви взагалі будете звертати увагу. Якщо поле не входить до allowlist, відхиляйте запит або видаляйте цей ключ. Це змінює стандартну позицію: нові колонки бази даних є неможливими для запису, доки розробник явно не відкриє їх. Схеми з часом розростаються. Колега додає stripeCustomerId, departmentBudget або прапорець isVerified. За наявності allowlist ці нові колонки автоматично захищені від записів з боку клієнта. Без нього кожна нова колонка стає випадковою поверхнею атаки API.

Валідні значення

Щойно ви визначите, які поля дозволені, перевірте, чи мають значення сенс. Чи є рядок часового поясу дійсно розпізнаним часовим поясом? Чи відформатований email як email? Чи знаходиться число в розумних межах? Це гігієна. Це запобігає потраплянню сміття у вашу систему, але це не зупиняє зловживання. Ідеально валідний рядок "admin" все одно залишається небезпечним у полі role, якщо його надсилає не та особа.

Авторизовані переходи

Це та брама, яку більшість команд пропускає, але саме тут криється справжній захист. Поставте точне запитання: чи має цей конкретний суб'єкт дозвіл змінювати це конкретне поле в цьому конкретному записі? Не «чи є користувач адміном?». Не «чи має користувач область доступу write:users?». А саме: «чи дозволено цьому користувачеві змінювати свій власний displayName, але ніколи не змінювати свій accountId?». Авторизація на рівні окремих полів не дозволяє широким дозволам, таким як «Редактор» або «Користувач», перетворитися на універсальний ключ до кожної властивості в рядку.

Побудова функції patch

Об'єднайте ці три брами в єдиний конвеєр (pipeline). Коли надходить patch-запит, пропускайте його через ці етапи по черзі.

По-перше, відфільтруйте вхідні дані відповідно до вашого allowlist. Якщо role не є дозволеним полем для цього ендпоінту, зупиніться на цьому етапі. Немає сенсу валідувати або авторизувати значення, яке ви ніколи не мали отримувати.

По-друге, перевірте дозволені значення. Перевіряйте типи, формати та бізнес-правила. Поле локації має бути рядком, що відповідає реальному часовому поясу. URL-адреса аватара має бути валідним URI певної довжини.

По-третє, авторизуйте дію. Переконайтеся, що суб'єкт володіє цільовим записом або має саме те право, яке необхідне для цього поля. Володіння є хорошим варіантом за замовчуванням для персональних даних, але деяким полям все одно потрібні додаткові брами. Користувач може володіти своїм профілем, проте лише адміністратор платіжної системи повинен мати доступ до taxRegion.

По-четверте, нормалізуйте дані. Видаляйте зайві пробіли, скорочуйте повторювані пробіли, переводьте email у нижній регістр або видаляйте керуючі символи. Робіть це після валідації, але перед збереженням, щоб ви не порівнювали «брудні» рядки під час перевірок авторизації.

If the input fails any gate, reject the entire mutation. Do not partially apply the safe fields and quietly drop the bad ones. A mixed response trains clients to spray every key they can think of and see what sticks. Fail explicitly.

The Edge Cases That Actually Matter

Mass assignment defenses live or die on details that unit tests often miss.

Duplicate JSON keys. Attackers can send payloads like {"role": "user", "role": "admin"}. Depending on your HTTP parser and framework, the second value might overwrite the first before your application code sees the object. Test this behavior at the parser level. If your framework silently accepts the last key, your allowlist might be looking at "user" while the database receives "admin".

Nested objects, nulls, and arrays. Do not assume the payload is flat. A client might wrap a restricted field inside a nested object like { "profile": { "role": "admin" } }. Your allowlist must recurse if your schema does. Likewise, decide how you handle null. Does it mean "ignore this field" or "delete this field"? And if an array is expected, does your validator reject unexpected structures, or does it cast a single object into an array and let it through?

Unicode normalization. Two strings can look identical to a human while being different sequences of bytes. A user might send a precomposed é or a decomposed e plus combining accent. If your authorization check normalizes once but your storage layer normalizes differently, you can end up with inconsistent data or, worse, a bypass where a username collision slips past your logic. Normalize early and normalize consistently.

Race conditions. Authorization decisions are not freeze frames. They happen at a point in time. Two requests can read the same record, both see that the actor is allowed to write, and both issue updates. In between, the state or the actor’s permissions might have changed. Always apply database updates with a condition on a version number or a state machine value. Use something like UPDATE users SET ... WHERE id = ? AND version = 5. If the row changed since you read it, the write fails. Handle the failure by retrying or rejecting. This keeps stale authorization checks from corrupting your data.

Monitor What Matters

You cannot secure what you cannot see. Build your audit logging around the decision, not just the action.

Log the actor ID and the target ID. Log the exact field names that were accepted and the ones that were rejected. Log the policy version that made the decision and the final result. If a user suddenly starts getting role rejected in their profile update, you want to know immediately.

Never log bearer tokens. Never dump entire request bodies into your logs. An audit trail should help you investigate abuse, not become a repository of credentials and personal data.

The Only Rule You Need

A request body proposes data. It never defines its own authority. The client can ask for anything. Your server decides, field by field and row by row, what is allowed to land in permanent storage. Build your patches with that separation in mind, and mass assignment becomes a problem you stopped long before it reached your authorization layer.