Одна строка кода может свести на нет всю вашу модель управления доступом. Если вы просто передаете тело запроса в метод обновления базы данных, вы фактически даете клиенту в руки перо, чтобы он мог переписать вашу схему. Это и есть Mass Assignment. Это не экзотический баг и не редкий случай. Это ошибка проектирования, возникающая всякий раз, когда API воспринимает полезную нагрузку как собственную политику обновления.
await db.users.update(req.params.id, { ...req.body });
Это выглядит чисто. Это экономит время на написание кода. Но ключи контролирует клиент. Злоумышленник может добавить "role": "admin", "accountId": "someone_else" или "credit": 99999 к обычному обновлению профиля. Ваш слой валидации может проверить, являются ли эти значения строками или числами, и решить, что с ними всё в порядке. Однако валидность — это не авторизация. Пользователь может законно владеть целевой записью, но это не значит, что он должен иметь право редактировать каждое поле внутри неё.
Как на самом деле выглядит Mass Assignment
Опасность кроется в удобстве. Фреймворки и ORM позволяют тривиально сопоставлять ключи JSON напрямую с колонками базы данных. Делая это, вы приказываете базе данных доверять клиенту в вопросе того, что именно должно измениться, а не только того, как это должно измениться.
Пользователь, обновляющий свой профиль, может отправить корректные данные для displayName и bio, но незаметно подмешать к ним role или balance. Если ваш контроллер просто пересылает объект дальше, база данных запишет всё. Валидация отловит некорректные типы данных, но она редко ловит вредоносные ключи. Бизнес-правило, гласящее «этому пользователю разрешено обновлять свой профиль», превращается в безграничное разрешение на изменение любой колонки в строке.
Решение заключается не в усилении валидации, а в более строгой архитектуре.
Три рубежа защиты
Безопасная мутация проходит через три отдельных проверки, прежде чем коснуться хранилища.
Разрешенные поля (Allowlist)
Начните с того, чтобы точно определить, на какие ключи вы вообще будете смотреть. Если поля нет в «белом списке» (allowlist), отклоните запрос или отбросьте этот ключ. Это меняет подход по умолчанию: новые колонки базы данных становятся защищены от записи, пока разработчик явно не разрешит их использование. Схемы со временем растут. Коллега добавляет stripeCustomerId, departmentBudget или флаг isVerified. При использовании allowlist эти новые колонки будут автоматически защищены от записи со стороны клиента. Без него каждая новая колонка становится случайной точкой атаки через API.
Валидные значения
Как только вы определили разрешенные поля, проверьте, имеют ли смысл их значения. Является ли строка часового пояса распознаваемым? Форматирован ли email как email? Находится ли число в разумном диапазоне? Это гигиена данных. Это предотвращает попадание мусора в вашу систему, но это не останавливает злоупотребления. Идеально валидная строка "admin" всё равно опасна в поле role, если её отправил не тот человек.
Авторизованные изменения
Это тот рубеж, который большинство команд пропускает, хотя именно здесь кроется настоящая защита. Задайте гранулярный вопрос: имеет ли данный конкретный субъект разрешение изменять данное конкретное поле в данной конкретной записи? Не «является ли пользователь админом?», не «есть ли у пользователя область доступа write:users?», а именно: «разрешено ли этому пользователю менять свой собственный displayName, но никогда — свой accountId?». Авторизация на уровне полей не позволяет широким правам, таким как «Редактор» или «Пользователь», превращаться в универсальный ключ ко всем свойствам в строке.
Создание функции патча (Patch)
Объедините три рубежа в единый конвейер. Когда поступает PATCH-запрос, пропускайте его через этапы в строгом порядке.
Во-первых, отфильтруйте входные данные согласно вашему allowlist. Если role не является разрешенным полем для этого эндпоинта, немедленно остановите выполнение. Нет смысла валидировать или авторизовать значение, которое вы вообще не должны были получать.
Во-вторых, провалидируйте разрешенные значения. Проверьте типы, форматы и бизнес-правила. Поле местоположения должно быть строкой, соответствующей реальному часовому поясу. URL аватара должен быть валидным URI определенной длины.
В-третьих, авторизуйте действие. Убедитесь, что субъект владеет целевой записью или обладает именно теми правами, которые требуются для этого поля. Владение данными — хороший вариант по умолчанию для персональной информации, но некоторым полям всё равно нужны дополнительные проверки. Пользователь может владеть своим профилем, но только администратор биллинга должен иметь доступ к taxRegion.
В-четвертых, нормализуйте данные. Удалите лишние пробелы, схлопните повторяющиеся пробелы, приведите email к нижнему регистру или удалите управляющие символы. Делайте это после валидации, но перед сохранением, чтобы не сравнивать «грязные» строки во время проверок авторизации.
Если входные данные не проходят ни один из этапов проверки, отклоняйте всю мутацию целиком. Не применяйте частично безопасные поля и не отбрасывайте плохие значения молча. Смешанный ответ приучает клиентов «засыпать» запросами любые возможные ключи в надежде, что что-то сработает. Ошибайтесь явно.
Крайние случаи, которые действительно важны
Эффективность защиты от массового присваивания (mass assignment) зависит от деталей, которые часто упускают юнит-тесты.
Дублирующиеся ключи JSON. Злоумышленники могут отправлять полезную нагрузку вида {"role": "user", "role": "admin"}. В зависимости от вашего HTTP-парсера и фреймворка, второе значение может перезаписать первое еще до того, как объект попадет в код приложения. Проверяйте это поведение на уровне парсера. Если ваш фреймворк молча принимает последний ключ, ваш белый список (allowlist) может проверять "user", в то время как база данных получит "admin".
Вложенные объекты, null и массивы. Не предполагайте, что полезная нагрузка плоская. Клиент может обернуть ограниченное поле во вложенный объект, например { "profile": { "role": "admin" } }. Ваш белый список должен поддерживать рекурсию, если ее поддерживает ваша схема. Точно так же решите, как вы обрабатываете null. Означает ли это «игнорировать это поле» или «удалить это поле»? И если ожидается массив, отклоняет ли ваш валидатор неожиданные структуры или он преобразует одиночный объект в массив и пропускает его?
Нормализация Unicode. Две строки могут выглядеть идентично для человека, но представлять собой разные последовательности байтов. Пользователь может отправить предкомбинированный символ é или разложенный на e и комбинируемый акцент. Если ваша проверка авторизации выполняет нормализацию один раз, а слой хранения — иначе, вы можете получить несогласованные данные или, что еще хуже, обход защиты, когда коллизия имен пользователей проскочит мимо вашей логики. Нормализуйте данные на ранних этапах и делайте это единообразно.
Состояние гонки (Race conditions). Решения об авторизации — это не застывшие кадры. Они принимаются в определенный момент времени. Два запроса могут прочитать одну и ту же запись, оба увидят, что субъекту разрешена запись, и оба отправят обновления. За это время состояние или права субъекта могли измениться. Всегда применяйте обновления базы данных с условием по номеру версии или значению конечного автомата. Используйте что-то вроде UPDATE users SET ... WHERE id = ? AND version = 5. Если строка изменилась с момента чтения, запись не удастся. Обработайте ошибку путем повторной попытки или отклонения запроса. Это предотвратит повреждение данных из-за устаревших проверок авторизации.
Мониторьте то, что важно
Вы не можете защитить то, чего не видите. Стройте аудит не просто вокруг действия, а вокруг принятого решения.
Логируйте ID субъекта (actor ID) и ID цели (target ID). Логируйте точные имена полей, которые были приняты, и те, которые были отклонены. Логируйте версию политики, на основе которой было принято решение, и конечный результат. Если у пользователя внезапно начнут отклоняться обновления поля role в профиле, вы должны узнать об этом немедленно.
Никогда не логируйте bearer-токены. Никогда не сбрасывайте тела запросов целиком в логи. Журнал аудита должен помогать расследовать злоупотребления, а не становиться хранилищем учетных данных и персональных данных.
Единственное правило, которое вам нужно
Тело запроса лишь предлагает данные. Оно никогда не определяет свои собственные полномочия. Клиент может просить что угодно. Ваш сервер решает, поле за полем и строка за строкой, что именно может попасть в постоянное хранилище. Проектируйте свои патчи (patches) с учетом этого разделения, и проблема массового присваивания будет устранена задолго до того, как она достигнет уровня авторизации.
