Покупательница Анна открывает свой заказ /orders/1001, меняет в адресе одну цифру и видит заказ 1002: чужое имя, адрес доставки, телефон. Токен у неё настоящий, роль правильная, сервер ничего не нарушил по своим правилам. Он просто не спросил, чей это заказ.
У этой ошибки несколько имён. IDOR (Insecure Direct Object Reference) в шпаргалке OWASP - уязвимость, при которой атакующий читает или меняет объекты, подменяя идентификаторы в адресе или параметрах. В списке рисков API это API1:2023 Broken Object Level Authorization, BOLA. В каталоге слабостей - CWE-639 «Authorization Bypass Through User-Controlled Key»: авторизация не мешает одному пользователю получить запись другого, подменив ключ. Родовое понятие над ними - CWE-284 «Improper Access Control».
Всё это входит в A01:2025 Broken Access Control, первую строку OWASP Top 10:2025. Там же сказано, что у 100% протестированных приложений нашлась та или иная форма сломанного контроля доступа («100% of the applications tested were found to have some form of broken access control»). Рядом стоит родственная ошибка API5:2023 Broken Function Level Authorization, BFLA: не чужой объект, а чужая функция, например возврат денег, доступный обычному покупателю.
Как устроена атака и чем грозитспросят на собеседовании
Атакующему не нужно ничего взламывать. Он входит под своей учётной записью, смотрит, какие идентификаторы гуляют в запросах, и подставляет чужие. API1:2023 перечисляет, где они лежат: путь и строка запроса, заголовки, тело запроса. И отмечает, что об успехе обычно говорит сам ответ сервера. На маркетплейсе это выглядит так.
Перебор последовательных номеров. Заказы нумеруются подряд, значит, рядом с 1001 лежат 1000 и 1002. Скрипт на сотню строк идёт по диапазону и собирает адреса, телефоны и состав корзин. Ответ 200 с телом - добыча, 404 - пропуск.
Подмена в пакетных запросах. POST /orders/batch с телом {"ids": [1001, 1002, 1003]} для экрана «мои заказы» часто проверяет только первый элемент или не проверяет ничего: «список же формирует наш фронтенд». Пример из API1:2023 - мутация GraphQL deleteReports со списком ключей, которая удаляет документы без проверки прав.
Экспорт и «история». Основной эндпоинт GET /orders/{id} проверку получил, а написанные позже GET /orders/{id}/history, GET /payments/{id}/receipt и GET /orders/export?customerId=42 нет. Производные ресурсы раскрывают то же самое: чек содержит адрес и сумму, выгрузка отдаёт всё за год.
Чужие действия, а не только чтение. POST /orders/{id}/cancel без проверки владельца - это массовая отмена: цикл по номерам, и за ночь отменены заказы всех покупателей. Ровно такая ошибка оставлена в учебном демо-сервисе магазина, на котором тренируются тестировщики в опросе «API с собеседований тестировщика»: GET /orders/{id} чужой заказ не отдаёт, а POST /orders/{id}/cancel отменяет чужой заказ без вопросов. Это тренировочный стенд, а не настоящий продукт, но баг в нём списан с жизни: проверку поставили на чтение и забыли на изменение.
Чужие функции. API5:2023 предлагает задать три вопроса: может ли обычный пользователь вызвать административный эндпоинт; может ли он сделать опасное действие, просто сменив HTTP-метод (с GET на DELETE); может ли угадать адрес вроде /api/v1/users/export_all. И предупреждает: не считать эндпоинт обычным или административным по пути, админские функции часто живут рядом с обычными. На маркетплейсе это POST /orders/{id}/refund, который проверяет только «пользователь вошёл».
Последствия по API1:2023: раскрытие данных посторонним, потеря или подмена данных, а в отдельных случаях захват учётной записи. Для маркетплейса это утечка персональных данных покупателей, отменённые заказы продавцов и деньги, вернувшиеся не тому.
Как выглядит в коде
Обработчик ищет заказ по номеру из адреса и возвращает его. Аутентификация пройдена, роль «покупатель» есть, а вопрос «твой ли заказ» не задан ни разу. Программа ниже играет роль Анны и перебирает номера с 1001 по 1004:
live example
import java.util.Map;
import java.util.Optional;
public class VulnerableOrders {
record Order(long id, String customerId, String address) {}
static final Map<Long, Order> ORDERS = Map.of(
1001L, new Order(1001, "anna", "Тверская, 1"),
1002L, new Order(1002, "boris", "Ленина, 7"),
1003L, new Order(1003, "vera", "Садовая, 3"));
static Optional<Order> getOrder(long id) {
return Optional.ofNullable(ORDERS.get(id));
}
public static void main(String[] args) {
String caller = "anna";
for (long id = 1001; id <= 1004; id++) {
String answer = getOrder(id)
.map(o -> "200 " + o.customerId() + ", " + o.address()
+ (o.customerId().equals(caller) ? "" : " <- чужой заказ"))
.orElse("404");
System.out.println(caller + " GET /orders/" + id + " -> " + answer);
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import "fmt"
type Order struct {
ID int
CustomerID string
Address string
}
var orders = map[int]Order{
1001: {1001, "anna", "Тверская, 1"},
1002: {1002, "boris", "Ленина, 7"},
1003: {1003, "vera", "Садовая, 3"},
}
func getOrder(id int) (Order, bool) {
o, ok := orders[id]
return o, ok
}
func main() {
caller := "anna"
for id := 1001; id <= 1004; id++ {
o, ok := getOrder(id)
switch {
case !ok:
fmt.Printf("%s GET /orders/%d -> 404\n", caller, id)
case o.CustomerID != caller:
fmt.Printf("%s GET /orders/%d -> 200 %s, %s <- чужой заказ\n", caller, id, o.CustomerID, o.Address)
default:
fmt.Printf("%s GET /orders/%d -> 200 %s, %s\n", caller, id, o.CustomerID, o.Address)
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const orders = new Map([
[1001, { id: 1001, customerId: "anna", address: "Тверская, 1" }],
[1002, { id: 1002, customerId: "boris", address: "Ленина, 7" }],
[1003, { id: 1003, customerId: "vera", address: "Садовая, 3" }],
]);
function getOrder(id) {
return orders.get(id);
}
const caller = "anna";
for (let id = 1001; id <= 1004; id++) {
const o = getOrder(id);
const answer = !o
? "404"
: `200 ${o.customerId}, ${o.address}` + (o.customerId === caller ? "" : " <- чужой заказ");
console.log(`${caller} GET /orders/${id} -> ${answer}`);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
from dataclasses import dataclass
@dataclass
class Order:
id: int
customer_id: str
address: str
ORDERS = {
1001: Order(1001, "anna", "Тверская, 1"),
1002: Order(1002, "boris", "Ленина, 7"),
1003: Order(1003, "vera", "Садовая, 3"),
}
def get_order(order_id: int) -> Order | None:
return ORDERS.get(order_id)
caller = "anna"
for order_id in range(1001, 1005):
o = get_order(order_id)
if o is None:
answer = "404"
else:
answer = f"200 {o.customer_id}, {o.address}" + ("" if o.customer_id == caller else " <- чужой заказ")
print(f"{caller} GET /orders/{order_id} -> {answer}")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Два чужих адреса из трёх запросов. В настоящем сервисе на месте словаря стоит SELECT ... FROM orders WHERE id = ?, и ошибка та же: в запросе есть только то, что прислал клиент.
Как чинить: владение в самом запросеспросят на собеседовании
Шпаргалка OWASP по IDOR формулирует главное правило так: «implement access control checks for each object that users try to access», то есть проверять доступ к каждому объекту, к которому обращается пользователь. И показывает надёжную форму этой проверки: искать объект не во всей таблице, а среди объектов текущего пользователя (пример из Rails: current_user.projects.find(id) вместо Project.find(id)). A01:2025 говорит о том же: модель доступа должна обеспечивать владение записью, а не позволять любому пользователю создавать, читать, менять и удалять любую запись.
В SQL это одно условие: WHERE id = ? AND customer_id = ?. Номер заказа приходит из адреса, а покупатель берётся из проверенного токена, никогда из тела или параметров запроса. Обработчик получает getOrder(id, customerId), и для чужого заказа ответ тот же, что для несуществующего:
live example
import java.util.List;
import java.util.Optional;
public class FixedOrders {
record Order(long id, String customerId, String address) {}
static final List<Order> ORDERS = List.of(
new Order(1001, "anna", "Тверская, 1"),
new Order(1002, "boris", "Ленина, 7"),
new Order(1003, "vera", "Садовая, 3"));
static Optional<Order> getOrder(long id, String customerId) {
return ORDERS.stream()
.filter(o -> o.id() == id && o.customerId().equals(customerId))
.findFirst();
}
public static void main(String[] args) {
String caller = "anna";
for (long id = 1001; id <= 1004; id++) {
String answer = getOrder(id, caller)
.map(o -> "200 " + o.customerId() + ", " + o.address())
.orElse("404 заказ не найден");
System.out.println(caller + " GET /orders/" + id + " -> " + answer);
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import "fmt"
type Order struct {
ID int
CustomerID string
Address string
}
var orders = []Order{
{1001, "anna", "Тверская, 1"},
{1002, "boris", "Ленина, 7"},
{1003, "vera", "Садовая, 3"},
}
func getOrder(id int, customerID string) (Order, bool) {
for _, o := range orders {
if o.ID == id && o.CustomerID == customerID {
return o, true
}
}
return Order{}, false
}
func main() {
caller := "anna"
for id := 1001; id <= 1004; id++ {
if o, ok := getOrder(id, caller); ok {
fmt.Printf("%s GET /orders/%d -> 200 %s, %s\n", caller, id, o.CustomerID, o.Address)
} else {
fmt.Printf("%s GET /orders/%d -> 404 заказ не найден\n", caller, id)
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const orders = [
{ id: 1001, customerId: "anna", address: "Тверская, 1" },
{ id: 1002, customerId: "boris", address: "Ленина, 7" },
{ id: 1003, customerId: "vera", address: "Садовая, 3" },
];
function getOrder(id, customerId) {
return orders.find((o) => o.id === id && o.customerId === customerId);
}
const caller = "anna";
for (let id = 1001; id <= 1004; id++) {
const o = getOrder(id, caller);
const answer = o ? `200 ${o.customerId}, ${o.address}` : "404 заказ не найден";
console.log(`${caller} GET /orders/${id} -> ${answer}`);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
from dataclasses import dataclass
@dataclass
class Order:
id: int
customer_id: str
address: str
ORDERS = [
Order(1001, "anna", "Тверская, 1"),
Order(1002, "boris", "Ленина, 7"),
Order(1003, "vera", "Садовая, 3"),
]
def get_order(order_id: int, customer_id: str) -> Order | None:
return next((o for o in ORDERS if o.id == order_id and o.customer_id == customer_id), None)
caller = "anna"
for order_id in range(1001, 1005):
o = get_order(order_id, caller)
answer = f"200 {o.customer_id}, {o.address}" if o else "404 заказ не найден"
print(f"{caller} GET /orders/{order_id} -> {answer}")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Почему условие в запросе, а не if order.customerId != user после загрузки: проверку после загрузки забывают в одном из десяти обработчиков, а запрос без владельца просто не пишут, если метод репозитория требует его параметром. Для изменений то же самое, и это закрывает массовую отмену: UPDATE orders SET status = 'CANCELLED' WHERE id = ? AND customer_id = ? AND status = 'NEW' и проверка числа изменённых строк. Ноль строк означает «нет такого заказа у этого покупателя в этом состоянии», и между проверкой и действием не остаётся щели, в которую пролезет параллельный запрос. Для списков условие владельца стоит в самой выборке, подробнее в разделе «Списки» статьи где какая проверка.
Запрет по умолчанию и проверка функцииспросят на собеседовании
Владение отвечает на вопрос «твой ли объект». Второй вопрос, «можно ли тебе эту функцию вообще», и есть BFLA. A01:2025 ставит первым пунктом защиты «Except for public resources, deny by default»: всё, что не публично, закрыто, пока не открыто явно. API5:2023 уточняет: механизм проверки должен запрещать всё по умолчанию и требовать явного разрешения конкретным ролям для каждой функции, а административные функции внутри обычного контроллера проверять роль сами. Шпаргалка OWASP по авторизации объясняет зачем: правила пишут люди, они ошибаются, поэтому на запрос, под который не подошло ни одно правило, приложение отвечает отказом, а не пропускает.
На практике это три вещи: каждая ручка явно объявляет, какой роли она доступна; ручка без объявления недоступна никому; владение проверяется запросом к данным в обработчике. Первые две держит последнее правило цепочки вроде anyRequest().denyAll() и тест, который обходит все зарегистрированные маршруты без токена и с токеном без ролей. Как это устроено и где какой уровень проверки живёт в системе из шлюза, BFF и доменного сервиса, разобрано в статье где какая проверка, здесь повторять не будем. Административные функции внутри обычного контроллера проверяют роль сами, а не полагаются на путь /admin/. Администратору владение не проверяют, но каждое его действие над чужим заказом пишут в журнал: аудит действий администратора.
Непредсказуемые идентификаторы: смягчение, а не починкаспросят на собеседовании
Первая реакция на перебор: «заменим номера на UUID». Это полезно, но дыру не закрывает. Шпаргалка по IDOR называет сложные идентификаторы мерой «defense-in-depth» и тут же оговаривает: проверка доступа нужна и с ними. Шпаргалка по мультиарендности говорит прямее: «Opaque, random identifiers can reduce enumeration, but they are not authorization controls».
Причина простая: UUID угадать нельзя, но его можно узнать. Он приходит в письме о доставке, в ссылке, которой покупатель поделился с курьером, в логах поддержки, в ответе соседнего эндпоинта вроде списка отзывов о продавце. API1:2023 прямо перечисляет UUID среди идентификаторов, которыми атакуют. Поэтому порядок такой: сначала владение в запросе, потом наружные идентификаторы без порядка, чтобы перебор стал бессмысленным, а номер заказа не выдавал ваш оборот.
404 или 403 и мультиарендность
Чужой заказ в примере выше отвечает 404, как несуществующий. Шпаргалка по IDOR рекомендует так делать, когда само существование объекта чувствительно: искать с условием владельца и отдавать на оба случая одинаковый ответ, например 404. Когда же уместен 403, как сравнять время ответа и чем ограничить частоту перебора, подробно разобрано в статье ABAC и проверка владения ресурсом, здесь повторять не будем. Заметим только: проверка до входа в обработчик, например access-бин в @PreAuthorize, отказывает статусом 403, а условие в запросе даёт 404. Выбирайте осознанно.
Тот же класс ошибок на уровне компаний называется утечкой между арендаторами: продавец A видит товары и выручку продавца B. Правило из шпаргалки по мультиарендности: идентификатор арендатора от клиента - только выбор («Treat client-supplied tenant identifiers as selectors only»), а право действовать в этом арендаторе сервер проверяет по проверенной личности. Схемы изоляции и политику строк в PostgreSQL смотрите в разделе про мультиарендность статьи про ABAC.
Как проверить: матрица правспросят на собеседовании
Ошибку доступа не видно в обычной работе: свои заказы открываются, тесты на «счастливый путь» зелёные. Её ловит только запрос, который сделан от имени не того пользователя. A01:2025 требует, чтобы разработчики и QA включали функциональные проверки доступа в модульные и интеграционные тесты, а API1:2023 добавляет: не выкатывать изменения, которые ломают такие тесты.
Удобная форма - матрица прав. Шпаргалка OWASP по автоматизации тестов авторизации описывает её так: у каждого права два измерения, функция и логическая роль, их сводят в таблицу, и эта таблица становится входом для интеграционных тестов. Строки матрицы для заказа Бориса 1002 выглядят так: чужой покупатель не может ничего, владелец читает и отменяет, поддержка только читает, админ может всё, включая возврат денег и выгрузку. Тест под каждой ролью дёргает каждое действие и сверяет ответ с клеткой; ручка возврата, которая забыла проверить роль, даёт FAIL сразу в трёх строках. Новая ручка без строки в матрице не проходит ревью. Живой прогон такой матрицы на четырёх языках, с ошибками BOLA и BFLA и выводом «ожидали/получили», есть в статье как искать уязвимости.
В настоящем наборе каждое действие выполняется HTTP-запросом к сервису под токеном нужной роли, а матрица лежит в файле рядом с тестами. Шпаргалка по IDOR советует тот же порядок для объектов: завести пользователей A и B, создать объекты каждому и под A обращаться к объектам B, подменяя ссылки в запросах.
Что смотреть в ревью: каждый новый эндпоинт, который принимает идентификатор, в том числе в теле и в списке; каждый вызов поиска по одному id без владельца или арендатора; откуда берётся customerId (из токена или из запроса); какой роли объявлена ручка и что будет, если объявление забыть.
Типичные ошибки
- Владелец из запроса.
GET /orders/export?customerId=42илиcustomerIdв теле: сервер честно фильтрует по владельцу, только владельца назначает атакующий. Покупатель берётся из токена. - Сравнили id из пути с id из токена и успокоились. API1:2023 прямо пишет, что сравнение идентификатора из JWT с параметром запроса закрывает лишь малую часть случаев: в
/orders/{id}никакогоuserIdнет, владение нужно проверять по самому объекту. - Проверка на чтении, но не на изменении.
GETзакрыт,POST /cancel,/refund,/history,/receiptоткрыты. Матрица прав ловит это механически. - Проверка и действие порознь. Проверили владельца, потом
UPDATE ... WHERE id = ?. Условие владельца и состояния должно стоять в том же запросе, который меняет данные. - Админ-функция спрятана только в интерфейсе. Кнопки нет, а ручка отвечает любому вошедшему. Роль проверяет сервер, а путь
/admin/ничего не гарантирует. - UUID вместо проверки. Идентификатор утечёт через письмо, ссылку или соседний эндпоинт, и тогда защиты нет.
- Кэш без владельца. Ответ закэширован по ключу
order:1002, и следующий запрос другого пользователя получает его мимо проверки. В ключ входят пользователь или арендатор.
Глубже: пакетные, экспортные и производные эндпоинтырасширенное
Пакетная ручка - самое частое место, где проверку теряют, потому что её пишут как цикл по уже «проверенному» списку. Надёжная форма та же, что и для одного объекта: одна выборка WHERE id = ANY(?) AND customer_id = ?, затем сравнение числа найденных строк с числом запрошенных. Дальше решение за контрактом: либо отказ целиком, если хоть один идентификатор не найден, либо частичный ответ, в котором чужие ничем не отличаются от несуществующих. Чего делать нельзя: проверять первый элемент и доверять остальным или проверять владение в цикле, но удалять пакетом по исходному списку.
Экспорт и отчёты не принимают владельца параметром вовсе: выгрузка «мои заказы за год» строится от покупателя из токена, а выгрузка «все заказы» - отдельная функция администратора со своей ролью, лимитом и записью в журнал. Производные ресурсы (история статусов, чек, трек-номер, переписка с поддержкой по заказу) проверяют владение через родителя: JOIN orders o ON o.id = h.order_id WHERE h.order_id = ? AND o.customer_id = ?. И отдельно стоит вопрос, что видит продавец: у одного заказа несколько участников, у каждого свой срез полей, а это уже авторизация на уровне свойств, API3:2023.
Коротко
- IDOR, он же BOLA (API1:2023, CWE-639): сервер отдаёт или меняет объект по идентификатору от клиента, не проверив, что объект принадлежит вызывающему. BFLA (API5:2023): обычный пользователь вызывает функцию, предназначенную другой роли. Обе входят в A01:2025 Broken Access Control.
- Атакуют перебором номеров, подменой идентификаторов в теле и в пакетных запросах, через экспорт, историю и чеки, а также действиями: массовая отмена чужих заказов опаснее чтения.
- Чинят владением в самом запросе:
WHERE id = ? AND customer_id = ?для чтения иUPDATE ... WHERE id = ? AND customer_id = ? AND status = ...с проверкой числа строк для изменений. Покупатель только из токена. - Каждая функция явно объявляет роль, всё необъявленное запрещено. Административные ручки проверяют роль на сервере, где бы они ни лежали.
- UUID и другие случайные идентификаторы снижают перебор, но не заменяют проверку. Чужое на закрытых данных отвечает
404, как несуществующее. - Проверяют матрицей прав: роли по строкам, действия по столбцам, тест на каждую клетку, и под пользователем A запросы к объектам пользователя B.
Что почитать дальше
- ABAC и проверка владения ресурсом - access-бин против проверки в обработчике,
403против404, перебор и мультиарендность. - Где какая проверка: шлюз, BFF и доменный сервис - какой уровень за что отвечает и почему владение не проверяют на шлюзе.
- Аудит действий администратора - как администратору работать с чужими заказами и оставлять след.
- Нефункциональные виды тестирования - как тестировщик ищет чужие данные по прямой ссылке.