Покупатель маркетплейса меняет имя в профиле. Клиент шлёт {"name":"Ира К."}, сервер разбирает тело прямо в сущность покупателя и сохраняет. Через неделю в логах находится такой же запрос с добавкой "role":"admin","balance":100000,"isVerified":true, и все три поля легли в базу. А ответ на этот запрос вернул сущность целиком, вместе с passwordHash.
Это две стороны одной ошибки, которую OWASP в API Security Top 10 2023 свёл в категорию API3:2023 Broken Object Property Level Authorization: права проверили на объект, но не на его поля.
Как устроена атака и чем грозитспросят на собеседовании
OWASP называет две формы уязвимости, которые в редакции 2019 года были отдельными пунктами:
- Mass assignment (раньше API6:2019): эндпоинт позволяет менять, добавлять или удалять значение поля, которое пользователю менять нельзя. Слабость описана как CWE-915 «Improperly Controlled Modification of Dynamically-Determined Object Attributes».
- Excessive data exposure (раньше API3:2019): эндпоинт отдаёт поля, которые пользователь читать не должен. Её ссылка в OWASP - CWE-213 «Exposure of Sensitive Information Due to Incompatible Policies».
На маркетплейсе это выглядит так:
PATCH /api/meс"role":"admin"или"isVerified":true: покупатель сам себе выдаёт права или галочку проверенного продавца;PATCH /api/meс"balance":100000: деньги на внутреннем счёте появляются из воздуха;POST /api/ordersс"price":1: если сумма заказа берётся из тела, товар уходит за рубль;GET /api/sellers/{id}, который сериализует сущность продавца: наружу уходят хеш пароля, внутренние флаги модерации, телефон и паспортные данные владельца.
Поля атакующий не угадывает вслепую. OWASP пишет, что для поиска чувствительных данных достаточно посмотреть на ответы API, а скрытые поля для записи ищут перебором (fuzzing) и затем проверяют запросом. Последствия по OWASP: раскрытие, потеря или порча данных, а в отдельных случаях повышение привилегий и частичный или полный захват аккаунта.
Как выглядит в коде
Корень один: тело запроса привязывается к объекту, у которого полей больше, чем клиенту разрешено трогать, а ответ строится из того же объекта. Код ниже воспроизводит атаку без фреймворка: тело с лишними полями ложится в «сущность» покупателя, а ответ сериализует её целиком.
Jackson в песочнице нет, поэтому разбор JSON здесь ручной, а привязку делает рефлексия: так же, по имени поля, работает любой автоматический биндер.
живой пример
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MassAssignmentVulnerable {
static class Customer {
long id = 42;
String name = "Ира";
String role = "user";
long balance = 0;
boolean isVerified = false;
String passwordHash = "$2a$10$Q9xv1eF0";
}
static Map<String, String> parseFlatJson(String json) {
Map<String, String> fields = new LinkedHashMap<>();
Matcher m = Pattern.compile("\"(\\w+)\"\\s*:\\s*\"?([^\",}]*)\"?").matcher(json);
while (m.find()) {
fields.put(m.group(1), m.group(2));
}
return fields;
}
static void bindAll(Map<String, String> body, Object target) throws Exception {
for (var entry : body.entrySet()) {
Field field = target.getClass().getDeclaredField(entry.getKey());
Class<?> type = field.getType();
String raw = entry.getValue();
field.set(target, type == long.class ? Long.parseLong(raw)
: type == boolean.class ? Boolean.parseBoolean(raw) : raw);
}
}
static String toJson(Object source) throws Exception {
StringJoiner json = new StringJoiner(",", "{", "}");
for (Field field : source.getClass().getDeclaredFields()) {
json.add("\"" + field.getName() + "\":\"" + field.get(source) + "\"");
}
return json.toString();
}
public static void main(String[] args) throws Exception {
String body = "{\"name\":\"Ира К.\",\"role\":\"admin\",\"balance\":100000,\"isVerified\":true}";
Customer customer = new Customer();
bindAll(parseFlatJson(body), customer);
System.out.println("role=" + customer.role + " balance=" + customer.balance
+ " isVerified=" + customer.isVerified);
System.out.println("ответ: " + toJson(customer));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
encoding/json раскладывает ключи по полям структуры, а незнакомые ключи по умолчанию молча пропускает. Обратите внимание на "ROLE": документация пакета говорит, что Unmarshal предпочитает точное совпадение имени, но принимает и совпадение без учёта регистра.
живой пример
package main
import (
"encoding/json"
"fmt"
)
type Customer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Balance int64 `json:"balance"`
IsVerified bool `json:"isVerified"`
PasswordHash string `json:"passwordHash"`
}
func main() {
customer := Customer{ID: 42, Name: "Ира", Role: "user", PasswordHash: "$2a$10$Q9xv1eF0"}
body := []byte(`{"name":"Ира К.","ROLE":"admin","balance":100000,"isVerified":true}`)
if err := json.Unmarshal(body, &customer); err != nil {
panic(err)
}
fmt.Printf("role=%s balance=%d isVerified=%t\n", customer.Role, customer.Balance, customer.IsVerified)
response, _ := json.Marshal(customer)
fmt.Println("ответ:", string(response))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Object.assign копирует в объект все собственные свойства источника, а JSON.stringify отдаёт все поля, что есть.
живой пример
const customer = {
id: 42, name: 'Ира', role: 'user', balance: 0, isVerified: false,
passwordHash: '$2a$10$Q9xv1eF0',
};
const body = '{"name":"Ира К.","role":"admin","balance":100000,"isVerified":true}';
Object.assign(customer, JSON.parse(body));
console.log(`role=${customer.role} balance=${customer.balance} isVerified=${customer.isVerified}`);
console.log('ответ:', JSON.stringify(customer));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Цикл с setattr по ключам тела делает то же, что Model(**data) или model.__dict__.update(data) с ORM-моделью.
живой пример
import json
from dataclasses import asdict, dataclass
@dataclass
class Customer:
id: int
name: str
role: str = "user"
balance: int = 0
is_verified: bool = False
password_hash: str = "$2a$10$Q9xv1eF0"
customer = Customer(id=42, name="Ира")
body = '{"name": "Ира К.", "role": "admin", "balance": 100000, "is_verified": true}'
for key, value in json.loads(body).items():
setattr(customer, key, value)
print(f"role={customer.role} balance={customer.balance} is_verified={customer.is_verified}")
print("ответ:", json.dumps(asdict(customer), ensure_ascii=False))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Во всех четырёх случаях role стал admin, баланс вырос, а в ответе лежит хеш пароля. Проверка «пользователь меняет свой профиль» при этом пройдена честно: объект его, а вот поля уже нет.
Как чинить: отдельные DTO на вход и на выходспросят на собеседовании
OWASP Mass Assignment Cheat Sheet первой мерой называет отдельные входные объекты: «Prefer dedicated input objects, such as Data Transfer Objects (DTOs), containing only fields the caller may edit». В разделе «How To Prevent» категории API3 то же для ответа: не сериализовать объект целиком (to_json(), to_string()), а выбирать поля явно и держать ответ минимальным под задачу эндпоинта.
Отсюда схема из трёх частей:
- DTO запроса содержит только то, что этот эндпоинт разрешает менять. Поля
roleв нём нет вовсе, поэтому привязать его некуда. - Явное переложение из DTO в сущность:
customer.rename(request.name()), а не «скопировать все совпадающие свойства». - DTO ответа перечисляет поля, которые клиенту можно видеть. Новое поле в сущности само в ответ не попадёт.
Лишний ключ в теле можно молча выбросить или отклонить с 400. Оба варианта не дают ему лечь в объект, но отказ полезнее: ошибка клиента видна сразу, а попытка подставить role остаётся в логах как явный сигнал. Заодно в DTO проверяют размеры и диапазоны: длину имени, quantity от 1 до 100, формат телефона.
Запись UpdateProfileRequest знает свой список полей и отклоняет всё лишнее, ответ собирается в ProfileView.
живой пример
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MassAssignmentFixed {
static class Customer {
long id = 42;
String name = "Ира";
String role = "user";
long balance = 0;
String passwordHash = "$2a$10$Q9xv1eF0";
}
record UpdateProfileRequest(String name) {
static final Set<String> ALLOWED = Set.of("name");
static UpdateProfileRequest from(Map<String, String> body) {
for (String key : body.keySet()) {
if (!ALLOWED.contains(key)) {
throw new IllegalArgumentException("неизвестное поле " + key);
}
}
String name = body.getOrDefault("name", "");
if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("name: от 1 до 100 символов");
}
return new UpdateProfileRequest(name);
}
}
record ProfileView(long id, String name) {
static ProfileView of(Customer c) {
return new ProfileView(c.id, c.name);
}
}
static Map<String, String> parseFlatJson(String json) {
Map<String, String> fields = new LinkedHashMap<>();
Matcher m = Pattern.compile("\"(\\w+)\"\\s*:\\s*\"?([^\",}]*)\"?").matcher(json);
while (m.find()) {
fields.put(m.group(1), m.group(2));
}
return fields;
}
public static void main(String[] args) {
Customer customer = new Customer();
for (String body : new String[]{
"{\"name\":\"Ира К.\",\"role\":\"admin\",\"balance\":100000}",
"{\"name\":\"Ира К.\"}"}) {
try {
UpdateProfileRequest request = UpdateProfileRequest.from(parseFlatJson(body));
customer.name = request.name();
System.out.println("200 " + ProfileView.of(customer));
} catch (IllegalArgumentException e) {
System.out.println("400 " + e.getMessage());
}
}
System.out.println("role=" + customer.role + " balance=" + customer.balance);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Decoder.DisallowUnknownFields по документации возвращает ошибку, если в объекте есть ключ, которому нет экспортированного поля в структуре назначения.
живой пример
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"unicode/utf8"
)
type Customer struct {
ID int64
Name string
Role string
Balance int64
PasswordHash string
}
type UpdateProfileRequest struct {
Name string `json:"name"`
}
type ProfileView struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
func decodeUpdate(body []byte) (UpdateProfileRequest, error) {
var req UpdateProfileRequest
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return req, err
}
if n := utf8.RuneCountInString(req.Name); n < 1 || n > 100 {
return req, errors.New("name: от 1 до 100 символов")
}
return req, nil
}
func main() {
customer := Customer{ID: 42, Name: "Ира", Role: "user", PasswordHash: "$2a$10$Q9xv1eF0"}
for _, body := range []string{
`{"name":"Ира К.","role":"admin","balance":100000}`,
`{"name":"Ира К."}`,
} {
req, err := decodeUpdate([]byte(body))
if err != nil {
fmt.Println("400", err)
continue
}
customer.Name = req.Name
view, _ := json.Marshal(ProfileView{ID: customer.ID, Name: customer.Name})
fmt.Println("200", string(view))
}
fmt.Printf("role=%s balance=%d\n", customer.Role, customer.Balance)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Разрешённые ключи перечислены явно, тип и длина проверены до того, как значение тронет объект.
живой пример
const customer = { id: 42, name: 'Ира', role: 'user', balance: 0, passwordHash: '$2a$10$Q9xv1eF0' };
const ALLOWED = new Set(['name']);
function parseUpdateProfile(raw) {
const body = JSON.parse(raw);
const unknown = Object.keys(body).filter((key) => !ALLOWED.has(key));
if (unknown.length > 0) throw new Error(`неизвестные поля: ${unknown.join(', ')}`);
const { name } = body;
if (typeof name !== 'string' || name.length < 1 || name.length > 100) {
throw new Error('name: строка от 1 до 100 символов');
}
return { name };
}
const toProfileView = (c) => ({ id: c.id, name: c.name });
for (const raw of ['{"name":"Ира К.","role":"admin","balance":100000}', '{"name":"Ира К."}']) {
try {
const update = parseUpdateProfile(raw);
customer.name = update.name;
console.log('200', JSON.stringify(toProfileView(customer)));
} catch (e) {
console.log('400', e.message);
}
}
console.log(`role=${customer.role} balance=${customer.balance}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Разрешённые ключи берутся из полей dataclass запроса; лишний ключ отклоняется своим текстом, а не сообщением исключения Python, которое наружу не отдают.
живой пример
import json
from dataclasses import asdict, dataclass, fields
@dataclass
class Customer:
id: int
name: str
role: str = "user"
balance: int = 0
password_hash: str = "$2a$10$Q9xv1eF0"
@dataclass(frozen=True)
class UpdateProfileRequest:
name: str
def __post_init__(self):
if not isinstance(self.name, str) or not 1 <= len(self.name) <= 100:
raise ValueError("name: строка от 1 до 100 символов")
@dataclass(frozen=True)
class ProfileView:
id: int
name: str
def parse_update(raw: str) -> UpdateProfileRequest:
body = json.loads(raw)
allowed = {f.name for f in fields(UpdateProfileRequest)}
unknown = [key for key in body if key not in allowed]
if unknown:
raise ValueError(f"неизвестные поля: {', '.join(unknown)}")
return UpdateProfileRequest(**body)
customer = Customer(id=42, name="Ира")
for raw in ['{"name": "Ира К.", "role": "admin", "balance": 100000}', '{"name": "Ира К."}']:
try:
update = parse_update(raw)
except ValueError as e:
print("400", e)
continue
customer.name = update.name
print("200", json.dumps(asdict(ProfileView(customer.id, customer.name)), ensure_ascii=False))
print(f"role={customer.role} balance={customer.balance}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Атакующий запрос получил 400, обычный прошёл, role остался user, а в ответе только id и name.
Как это подключить в фреймворке
В рабочем сервисе ту же схему дают валидатор и сериализатор фреймворка. Сумму заказа и баланс клиент не присылает никогда: цену берут из каталога, баланс меняют только платёжные операции.
Записи как DTO, Bean Validation на размеры, явное переложение в сущность. ignoreUnknown = true в @JsonIgnoreProperties означает лишь «незнакомые свойства можно пропустить» и от mass assignment не защищает: role в сущности знакомое поле и привяжется. Если сущность всё же приходится отдавать или принимать, поле закрывают @JsonIgnore или @JsonProperty(access = READ_ONLY): такое свойство читается при сериализации, но не записывается при десериализации.
public record UpdateProfileRequest(
@NotBlank @Size(max = 100) String name,
@Pattern(regexp = "\\+7\\d{10}") String phone) {
}
public record ProfileView(long id, String name, String phone) {
static ProfileView of(Customer c) {
return new ProfileView(c.getId(), c.getName(), c.getPhone());
}
}
@RestController
@RequestMapping("/api/me")
class ProfileController {
private final CustomerRepository customers;
ProfileController(CustomerRepository customers) {
this.customers = customers;
}
@PatchMapping
ProfileView update(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody UpdateProfileRequest request) {
Customer customer = customers.findById(Long.parseLong(jwt.getSubject())).orElseThrow();
customer.rename(request.name());
customer.changePhone(request.phone());
return ProfileView.of(customers.save(customer));
}
}
@Entity
class Customer {
@Id private Long id;
private String name;
private String phone;
@JsonProperty(access = JsonProperty.Access.READ_ONLY) private String role;
@JsonIgnore private String passwordHash;
}
Отклонять лишние ключи, а не пропускать, включают настройкой spring.jackson.deserialization.fail-on-unknown-properties=true.
Отдельные структуры на вход и выход, DisallowUnknownFields и предел размера тела через http.MaxBytesReader. Тег json:"-" на чувствительных полях сущности оставляют вторым рубежом.
type Customer struct {
ID int64
Name string
Phone string
Role string `json:"-"`
PasswordHash string `json:"-"`
}
func (h *ProfileHandler) Update(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req UpdateProfileRequest
if err := dec.Decode(&req); err != nil {
http.Error(w, "некорректное тело запроса", http.StatusBadRequest)
return
}
if err := req.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
customer, err := h.customers.UpdateProfile(r.Context(), auth.CustomerID(r.Context()), req.Name, req.Phone)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ProfileView{ID: customer.ID, Name: customer.Name, Phone: customer.Phone})
}
В NestJS глобальный ValidationPipe с whitelist: true выбрасывает свойства без декораторов валидации, а forbidNonWhitelisted: true вместо этого отвечает ошибкой. Ответ собирается явно; если отдаёте класс сущности, поле закрывают @Exclude() из class-transformer вместе с ClassSerializerInterceptor.
export class UpdateProfileDto {
@IsString()
@Length(1, 100)
name: string;
@IsOptional()
@Matches(/^\+7\d{10}$/)
phone?: string;
}
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
@Controller('api/me')
export class ProfileController {
constructor(private readonly customers: CustomersService) {}
@Patch()
async update(@Req() req: AuthRequest, @Body() dto: UpdateProfileDto): Promise<ProfileView> {
const customer = await this.customers.updateProfile(req.user.id, { name: dto.name, phone: dto.phone });
return { id: customer.id, name: customer.name, phone: customer.phone };
}
}
В Pydantic extra по умолчанию 'ignore', а extra='forbid' даёт ValidationError на любое лишнее поле, и FastAPI отвечает 422. response_model ограничивает и фильтрует ответ до объявленных полей, даже если обработчик вернул ORM-объект целиком.
class UpdateProfileIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=100)
phone: str | None = Field(default=None, pattern=r"^\+7\d{10}$")
class ProfileOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
phone: str | None
@router.patch("/api/me", response_model=ProfileOut)
async def update_profile(
body: UpdateProfileIn,
user: CurrentUser,
session: AsyncSession = Depends(get_session),
):
customer = await session.get(Customer, user.id)
customer.name = body.name
customer.phone = body.phone
await session.commit()
return customer
Права на поля зависят от ролиспросят на собеседовании
Поле role кто-то менять всё же должен. Ошибка в том, чтобы оставить его в общем DTO и проверять «если не админ, проигнорируй». Надёжнее развести списки полей по ролям: покупатель правит профиль через PATCH /api/me с name и phone, а роль меняет администратор через PUT /api/admin/customers/{id}/role со своим DTO из одного поля, своей проверкой роли и записью в журнал административных действий. То же с ответом: продавец видит в заказе адрес доставки, другой покупатель в отзыве видит только имя автора.
Cheat Sheet отдельно оговаривает границу: DTO и allow-list ограничивают, какие поля можно записать, но не заменяют проверку прав на сам объект и операцию. Чей это профиль и чей заказ, решает проверка владения из статьи про IDOR и BOLA.
Как проверить
- Тест на лишнее поле. Для каждого изменяющего эндпоинта: тело с
role,balance,isVerified,price,ownerId. Ожидание: 400 или 422, и в базе значения не изменились. Проверять стоит именно базу, а не ответ: при молчаливом пропуске ответ 200, и тест без чтения из базы ничего не поймает. - Тест на состав ответа. Сравнивать множество ключей ответа с ожидаемым целиком, например ровно
id,name,phone. Тогда новое поле в сущности ломает тест, а не утекает молча. - Перебор полей. OWASP прямо называет fuzzing способом найти скрытые свойства: подставить в тело имена полей из ответов соседних эндпоинтов и посмотреть, что изменилось.
- Ревью. Искать копирование всего подряд (
Object.assign(entity, body),copyProperties(body, entity),setattrв цикле,Model(**data)), обработчики, которые принимают или возвращают класс сущности, и общий класс для запроса и ответа.
Типичные ошибкиспросят на собеседовании
- Считать, что «игнорировать незнакомые поля» защищает.
ignoreUnknownв Jackson,extra='ignore'в Pydantic, поведениеencoding/jsonпо умолчанию касаются только ключей, которых нет в целевом типе. Если целевой тип - сущность сrole, поле знакомое и привяжется. - Block-list вместо allow-list. Закрыть
roleаннотацией и забыть про добавленное через полгодаisVerified. Cheat Sheet: «Do not rely on a block-list as the sole protection»; CWE-915 тоже рекомендует allow-list. - Один DTO на запрос и ответ. В нём неизбежно оказываются
id,role,createdAt: их надо отдавать, но нельзя принимать. - DTO с декораторами на запретном поле.
whitelistв NestJS выбрасывает только свойства без декораторов; если в DTO есть@IsString() role, оно пройдёт. - Регистр ключей в Go.
encoding/jsonсопоставляет имена без учёта регистра, поэтому фильтр по точному имени"role"до разбора не остановит"ROLE". Решает отдельная структура, а не фильтр. - Нет пределов. Строгий DTO без длины и диапазона пропустит имя в мегабайт или
quantity: -5, от которого сумма заказа уйдёт в минус.
Глубже: GraphQL и выбор полей клиентомрасширенное
В REST лишние поля утекают, когда сервер отдаёт больше, чем надо. В GraphQL форму ответа выбирает клиент, и первый сценарий атаки в API3:2023 как раз про это: запрос жалобы на пользователя в приложении знакомств возвращает fullName и recentLocation того, на кого жалуются. Отсюда два правила: чувствительных полей вроде хеша пароля в схеме нет вообще, а право читать поле проверяется в резолвере этого поля, потому что одно поле доступно разными путями. Подробно об этом в статье про GraphQL, в разделе об авторизации в резолверах.
Глубже: случай GitHub, 2012расширенное
Cheat Sheet приводит GitHub как пример mass assignment. В посте от 4 марта 2012 года GitHub описал, что пользователь через уязвимость в форме обновления публичного ключа добавил свой ключ в организацию rails и в доказательство запушил в проект новый файл. Форма приняла от пользователя то, что он менять не должен был, и это стало правом записи в чужой репозиторий: тот же класс ошибки, что в примерах выше.
Коротко
- API3:2023 объединяет две ошибки на уровне полей: запись того, что менять нельзя (mass assignment, CWE-915), и выдачу того, что видеть нельзя (excessive data exposure, CWE-213).
- Тело запроса разбирают в DTO с полями, которые этот эндпоинт разрешает менять, и перекладывают в сущность явно; лишний ключ лучше отклонять с 400, чем молча выбрасывать.
- Ответ строят из отдельного DTO; сущность наружу не отдают, а чувствительные поля дополнительно закрывают (
@JsonIgnore,json:"-",@Exclude(),response_model). - «Игнорировать незнакомые поля» от mass assignment не спасает; спасает отсутствие поля в целевом типе или запрет на запись (
READ_ONLY,DisallowUnknownFields,forbidNonWhitelisted,extra='forbid'). - Цена, баланс, роль и признак проверки приходят из сервера или из отдельного эндпоинта для роли с правом их менять, а не из тела клиента.
- Тесты проверяют базу после запроса с лишними полями и точный набор ключей ответа.
Что почитать дальше
- IDOR и BOLA - проверка прав на объект, без которой правильный набор полей не поможет.
- PII и секреты - где ещё утекают персональные данные: логи, тексты ошибок, сообщения.
- ABAC и проверка владения ресурсом - как проверять, что заказ принадлежит покупателю.
- Аудит административных команд - как записывать смену роли и прочие админские действия.
- GraphQL - авторизация по полям в резолверах.