Чрезмерная выдача данных и mass assignment
Учимся видеть две зеркальные ошибки: API отдаёт лишние поля наружу и принимает лишние поля внутрь — и закрывать обе явными схемами с allow-list.
Чрезмерная выдача данных (Excessive Data Exposure) — API возвращает больше полей, чем нужно клиенту, полагаясь, что лишнее «спрячет» фронтенд. Mass assignment — обработчик слепо присваивает модели все поля из тела запроса, включая те, что менять нельзя.
Это две стороны одной привычки — доверять «форме данных» и не описывать её явно. Разбираем на стенде (crAPI, своё демо в ВМ), чтобы научиться проектировать API, который отдаёт и принимает ровно то, что задумано. Выгрузка чужих персональных данных из реального сервиса — это нарушение и 152-ФЗ, и ст. 272 УК РФ.
Зачем это знать защитнику
Обе ошибки незаметны при «счастливом пути»: приложение работает, тесты зелёные, на экране всё корректно. Опасность раскрывается, только если посмотреть на сырой ответ API и подумать, какие поля туда не должны были попасть, и наоборот — какие поля клиент может незаметно подсунуть на вход. Это мышление «что лишнего проходит через границу» и отличает защищённый API.
Особенно коварно то, что обе ошибки расширяются сами собой. Добавили в таблицу users новую колонку — и она тут же начала уезжать в ответе, если он строится из модели целиком. Добавили поле в форму — и mass assignment молча принял ещё один вектор. То есть уязвимость появляется не в момент написания кода, а позже, при обычной эволюции схемы, когда про этот эндпоинт уже забыли. Защита, привязанная к явному списку полей, разрывает эту связь: новые колонки по умолчанию остаются «внутренними».
Чрезмерная выдача: «фронт всё равно спрячет»
Самый частый источник — вернуть из обработчика модель целиком, как она лежит в базе. Уязвимый код:
@app.get("/api/users/{uid}")
def get_user(uid: int, _ = Depends(current_user)):
user = db.users.find_by_id(uid)
return user # вернёт ВСЕ поля строки как есть
Фронтенд рисует только имя и аватар, но в JSON уезжает всё:
{
"id": 42,
"name": "Ирина",
"email": "[email protected]",
"password_hash": "$2b$12$Q....",
"is_admin": false,
"reset_token": "a91f...",
"internal_notes": "VIP, скидка 20%"
}
Любой, кто откроет ответ в DevTools или через curl, увидит хеш пароля, токен сброса и внутренние пометки. Хеш пароля и reset_token — это прямой путь к захвату учётной записи. Прятать поля на клиенте бессмысленно: клиент видит всё, что прислал сервер.
Mass assignment: лишние поля на входе
Зеркальная ошибка — слепо записать в модель всё, что прислал клиент. Уязвимое обновление профиля:
@app.patch("/api/users/{uid}")
def update_user(uid: int, body: dict, user = Depends(current_user)):
# берём ВСЕ ключи из тела и пишем в модель
db.users.update(uid, **body) # <-- опасное присваивание всего подряд
return {"status": "ok"}
Клиент должен менять имя, а присылает на стенде лишнее:
{ "name": "Ирина", "is_admin": true, "balance": 999999 }
Поля is_admin и balance тоже запишутся — пользователь повысит себе права и баланс. Этот класс ошибок известен как mass assignment (его громкая иллюстрация — старый инцидент в GitHub на Rails). Корень тот же: входные данные приняты «как есть», без явного списка разрешённого.
Как это работает под капотом
В обоих случаях API использует одну и ту же структуру для трёх разных задач: хранение в базе, чтение клиентом и запись клиентом. Но у них разные требования. То, что лежит в БД (хеши, флаги, служебные токены), не равно тому, что можно показать. То, что хранится, не равно тому, что клиенту дозволено менять. Границу делают явной отдельные схемы: response model (что выходит) и input model (что разрешено принять). Без них границы определяет случайная форма таблицы.
Как защититься
1. Явная схема ответа (allow-list на выходе). Описывайте отдельную модель ответа, в которой перечислены только публичные поля. Фреймворк сам отбросит лишнее, даже если объект в базе обзаведётся новыми колонками:
class UserOut(BaseModel):
id: int
name: str
avatar_url: str | None
# password_hash, reset_token, is_admin сюда НЕ попадают
@app.get("/api/users/{uid}", response_model=UserOut)
def get_user(uid: int, _ = Depends(current_user)):
return db.users.find_by_id(uid) # лишние поля будут отрезаны схемой
2. Явная схема входа (allow-list на входе). Принимайте не dict, а модель с разрешёнными к изменению полями. Всё, чего в ней нет (is_admin, balance), игнорируется и не доходит до базы:
class UserUpdate(BaseModel):
name: str | None = None
avatar_url: str | None = None
# привилегированных полей здесь нет
@app.patch("/api/users/{uid}")
def update_user(uid: int, body: UserUpdate, user = Depends(current_user)):
data = body.model_dump(exclude_unset=True) # только присланные разрешённые поля
db.users.update(uid, **data)
return {"status": "ok"}
3. Allow-list, а не block-list. Перечисляйте что разрешено, а не что запретить. Чёрный список («не отдавать password_hash») неизбежно отстанет: добавится новое поле — и оно утечёт. Белый список безопасен по умолчанию.
4. Обнаружение и ревью. Просматривайте сырые ответы API на лишние поля (это можно автоматизировать тестом контракта против OpenAPI-схемы). На вход логируйте попытки прислать поля вне схемы — это сигнал прощупывания mass assignment.
Итоги
- Чрезмерная выдача и mass assignment — зеркальные ошибки: лишнее уходит наружу и лишнее проходит внутрь.
- Клиенту видно всё, что прислал сервер; «спрятать поле на фронте» — не защита.
- Разделяйте модели хранения, ответа и ввода: response model для выхода, input model для входа.
- Везде allow-list (перечисляем разрешённое), а не block-list (перечисляем запретное) — белый список безопасен по умолчанию.
- Ревьюйте сырые ответы и логируйте поля вне схемы. Практика — только на стенде; чужие персональные данные защищены 152-ФЗ и ст. 272 УК РФ.