CodeAuditAgent
Все статьи
  • Безопасность
  • 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.