- Безопасность
- OWASP
- Контроль доступа
IDOR и нарушение контроля доступа: практическое руководство
Как IDOR и нарушения контроля доступа проникают в REST, GraphQL и route handlers Next.js, как их тестировать и какие исправления работают в реальных проектах.
· Чтение: 7 мин · Lina Source LLC
Нарушение контроля доступа — класс ошибок, который переживает любое обновление фреймворка. ORM экранирует SQL, шаблонизатор экранирует HTML, но ничто в стеке не знает, что счёт 4812 принадлежит Алисе, а не Бобу. Это знание живёт в вашем коде, и если хотя бы один обработчик забудет его применить, любой вошедший в систему пользователь сможет прочитать или изменить чужие данные.
Самая распространённая форма — небезопасная прямая ссылка на объект, или IDOR: клиент передаёт идентификатор, сервер загружает запись с этим идентификатором, и никто не проверяет, имеет ли вызывающий право её видеть. Для эксплуатации не нужны специальные инструменты: достаточно браузера, второй учётной записи и изменённого числа в URL. Сканеры, которые ищут опасные вызовы функций, редко это находят, потому что в уязвимом коде нет ничего опасного: в совершенно обычном запросе к базе данных просто не хватает одного условия.
Три CWE, с которыми вы столкнётесь
- CWE-639, обход авторизации через ключ, контролируемый пользователем: классический IDOR. Запись выбирается по ID, который контролирует атакующий, а принадлежность никогда не проверяется.
- CWE-862, отсутствие авторизации: обработчик вообще не выполняет проверку прав. Часто это административный или внутренний эндпоинт, который считался недоступным извне.
- CWE-285, некорректная авторизация: проверка есть, но она неверна. Проверяется не то поле, при записи проверяется право на чтение или роль берётся из данных клиента.
Это различие важно при исправлении. Отсутствующую проверку нужно добавить; некорректная проверка означает, что неверна сама модель того, кто что может делать, и та же ошибка, скорее всего, повторяется в других местах. Найдя любую из них, поищите её «родственников», прежде чем закрывать тикет. Ошибки контроля доступа редко бывают единичными: они следуют шаблонам, которые команда копирует из обработчика в обработчик.
Как это происходит в route handler Next.js
Вот этот шаблон в самом типичном виде. Обработчик аутентифицирует пользователя — это создаёт ощущение безопасности, — а затем загружает запись только по её ID. Проверка сессии отвечает на вопрос, кто обращается; на вопрос, может ли этот пользователь видеть этот счёт, не отвечает ничто.
// app/api/invoices/[id]/route.ts (уязвимо)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// Любой вошедший пользователь прочитает любой счёт, изменив ID
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}Исправление — сделать принадлежность частью самого запроса, а не отдельным шагом, о котором можно забыть. Если запись не принадлежит вызывающему, база данных ничего не вернёт, и обработчик ответит 404.
// app/api/invoices/[id]/route.ts (исправлено)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}Ответ 404 вместо 403 выбран намеренно. Код 403 подтверждает, что запись существует, и позволяет атакующему перебирать действительные ID, даже если прочитать их он не может. То же касается времени ответа и сообщений об ошибках: ответ на запрос чужой записи должен быть неотличим от ответа на запрос записи, которой никогда не было.
REST: эндпоинты, о которых забывают
Очевидный GET по ID команды обычно защищают. Ошибки прячутся в других HTTP-методах и на периферии API:
- Обработчики PATCH и DELETE, скопированные с обработчика GET ещё до того, как в него добавили проверку принадлежности.
- Вложенные маршруты вида /projects/:projectId/tasks/:taskId, где проект проверяется, а задача загружается только по taskId и может принадлежать другому проекту.
- Массовые эндпоинты, которые принимают массив ID и проверяют только первый.
- Скачивание файлов и задачи экспорта, которые часто идут через отдельный сервис со своими, более слабыми проверками.
- Данные обновления, которые принимают ownerId, organizationId или role из тела запроса и записывают их прямо в базу (mass assignment).
GraphQL расширяет поверхность атаки
В GraphQL один и тот же объект достижим разными путями. Проверка на уровне запроса invoice(id) не поможет, если тот же счёт доступен через customer { invoices }, через поиск node(id) или через возвращаемый тип мутации. Каждый резолвер, возвращающий объект, — это точка входа. Слои пакетной загрузки вроде DataLoader добавляют ещё одну ловушку: загрузчик с ключом только по ID охотно вернёт записи любому пользователю, а его кеш, если он общий для нескольких запросов, может отдать данные одного пользователя следующему запросу.
Надёжный подход — авторизовать в слое данных, который вызывают резолверы, а не в самих резолверах. Если каждый путь к счёту проходит через одну функцию, которая принимает текущего пользователя и ограничивает запрос, новое поле или связь не смогут её обойти. Проверяйте и входные данные мутаций: поле вроде ownerId во входном типе — это приглашение переназначить записи. И наконец, помните, что интроспекция и сообщения об ошибках раскрывают вашу схему, поэтому исходите из того, что атакующий знает каждое поле и каждую связь, которые вы открываете.
Та же ошибка в Python
В FastAPI с SQLAlchemy картина та же. Уязвимая версия вызывает db.get(Document, doc_id); исправленная фильтрует по владельцу в том же запросе.
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# Уязвимо: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return docИсправления, которые работают
Ограничивайте каждый запрос владельцем или тенантом
Добавляйте ID пользователя или организации в условие WHERE при каждом чтении и каждой записи. Так авторизация становится свойством самого запроса, и это легко заметить на ревью. В мультитенантных приложениях row-level security в Postgres может обеспечивать границу тенанта как второй уровень защиты: забытый фильтр вернёт пустой результат, а не строки другого клиента. То же касается записи. Обновление должно быть одной командой с фильтром и по ID, и по владельцу — например, updateMany с обоими условиями и последующей проверкой, что изменилась ровно одна строка, — а не последовательностью «прочитать, проверить, записать», в которой возможна гонка.
Централизуйте решение
Разбросанные по коду проверки if постепенно расходятся. Небольшой набор хелперов, по одному на ресурс, держит правило в одном месте, и обработчик без вызова хелпера сразу бросается в глаза.
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// Запрет по умолчанию: нет членства или нет права — ответ одинаковый
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Запрещайте по умолчанию
Неизвестные роли, отсутствующее членство и неожиданные действия должны приводить к отказу. Во фреймворках с middleware требуйте аутентификацию для всего и явно помечайте публичные маршруты, а не наоборот. Новый маршрут должен быть закрыт, пока кто-то не решит иначе. Никогда не берите роль, тенант или ID пользователя из тела запроса или заголовка, который задаёт клиент; каждый раз получайте их на сервере из проверенной сессии.
Случайные идентификаторы вроде UUID использовать стоит, но это не исправление. ID утекают через URL, логи, общие ссылки и заголовки Referer. Считайте их неугадываемыми лишь в том смысле, что они замедляют перебор, но никогда не используйте их вместо проверки доступа.
Как это тестировать
Тестирование на IDOR простое и однообразное, поэтому его стоит автоматизировать, как только вы один раз проделали его вручную. Начните с инвентаризации: перечислите все маршруты, резолверы и фоновые задачи, которые принимают идентификатор, включая ID, спрятанные в теле запроса, строке запроса и заголовках.
- Создайте две учётные записи, A и B, желательно в двух разных организациях. Создайте запись от имени A и запишите её ID.
- Повторите каждый запрос, ссылающийся на этот ID, с сессией B: GET, PATCH, DELETE, скачивания, экспорт и любые запросы и мутации GraphQL, которые её затрагивают.
- Везде ожидайте 404. Любой ответ 200, как и любой 403, подтверждающий существование записи, — это находка.
- Превратите ручную проверку в интеграционный тест для каждого ресурса, чтобы новый обработчик без ограниченного запроса падал в CI.
- Найдите через grep выборки только по первичному ключу, например findUnique({ where: { id } }) или db.get(Model, id), и обоснуйте каждую.
Ревью кода ловит то, что пропускают тесты: отсутствующая проверка видна в исходниках, даже если для этого маршрута никто не написал тест. CodeAuditAgent читает публичный репозиторий GitHub или вставленный фрагмент кода и сообщает о пробелах в контроле доступа с указанием CWE, цитатой строки, сценарием эксплуатации и предлагаемым патчем — быстрый способ получить второй взгляд сразу на все обработчики.
Краткий чек-лист
- Каждый запрос, принимающий ID от клиента, также фильтрует по пользователю или тенанту вызывающего.
- Авторизация находится в общих хелперах или в слое данных, а не в скопированных проверках if.
- Неизвестные случаи приводят к отказу; публичные маршруты — явное исключение.
- Операции записи проверяются так же тщательно, как чтение, включая массовые и вложенные маршруты.
- Для каждого ресурса есть тесты с двумя учётными записями, и они запускаются в CI.