Чрезмерная выдача данных и 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 УК РФ.
Проверьте себя
1. Почему «спрятать лишние поля на фронтенде» не защищает от чрезмерной выдачи данных?
AКлиент получает весь JSON, который прислал сервер, и видит скрытые поля в DevTools или через curl; фильтровать нужно на сервере явной схемой ответа
BФронтенд не умеет скрывать поля, поэтому они всегда отображаются на экране
CПоля шифруются, но ключ хранится в том же ответе
DПроблема только в GraphQL, где клиент сам выбирает поля
2. Что защищает от mass assignment при обновлении профиля?
AПеред записью удалять из тела поле is_admin (чёрный список запретных полей)
BПринимать не произвольный dict, а input-модель с явным allow-list полей; присланные is_admin и balance, которых нет в модели, отбрасываются
CЗаменить PATCH на PUT, чтобы клиент присылал весь объект целиком
DСкладывать тело запроса в БД как есть, а валидировать позже отдельной задачей