← назад к разделу

Покупательница Анна открывает свой заказ /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:

живой пример

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);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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)
		}
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Два чужих адреса из трёх запросов. В настоящем сервисе на месте словаря стоит 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), и для чужого заказа ответ тот же, что для несуществующего:

живой пример

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);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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)
		}
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

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}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Почему условие в запросе, а не 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.

Что почитать дальше