Przejdź do treści
CodeAuditAgent
Wszystkie artykuły

IDOR i błędna kontrola dostępu: praktyczny przewodnik

Jak IDOR i błędna kontrola dostępu wkradają się do REST, GraphQL i route handlerów Next.js, jak je testować i jakie poprawki sprawdzają się w realnym kodzie.

· 7 min czytania · Lina Source LLC

Błędna kontrola dostępu to klasa błędów, która przeżywa każdą aktualizację frameworka. Twój ORM escapuje SQL, silnik szablonów escapuje HTML, ale nic w całym stosie nie wie, że faktura 4812 należy do Alicji, a nie do Bartka. Ta wiedza żyje w Twoim kodzie, a gdy jeden handler zapomni ją zastosować, dowolny zalogowany użytkownik może odczytać lub zmienić cudze dane.

Najczęstsza postać to niezabezpieczone bezpośrednie odwołanie do obiektu, czyli IDOR: klient wysyła identyfikator, serwer ładuje rekord o tym identyfikatorze i nikt nie sprawdza, czy wywołujący ma prawo go zobaczyć. Wykorzystanie tego nie wymaga żadnych specjalnych narzędzi. Wystarczy przeglądarka, drugie konto i zmieniona liczba w adresie URL. Skanery szukające niebezpiecznych wywołań funkcji rzadko to wychwytują, bo podatny kod nie zawiera niczego niebezpiecznego: całkowicie zwyczajne zapytanie do bazy danych po prostu nie ma jednego warunku.

Trzy identyfikatory CWE, które zobaczysz

  • CWE-639, obejście autoryzacji przez klucz kontrolowany przez użytkownika: klasyczny IDOR. Rekord jest wybierany po identyfikatorze, który kontroluje atakujący, a własność nigdy nie jest sprawdzana.
  • CWE-862, brak autoryzacji: handler w ogóle nie wykonuje sprawdzenia uprawnień. Często to endpoint administracyjny lub wewnętrzny, o którym założono, że jest nieosiągalny.
  • CWE-285, nieprawidłowa autoryzacja: sprawdzenie istnieje, ale jest błędne. Sprawdza nie to pole, sprawdza prawo do odczytu przy zapisie albo ufa roli przysłanej przez klienta.

To rozróżnienie ma znaczenie przy naprawie błędu. Brak sprawdzenia oznacza, że trzeba je dodać; nieprawidłowe sprawdzenie oznacza, że błędny jest sam model tego, kto co może zrobić, a ta sama pomyłka prawdopodobnie powtarza się gdzie indziej. Gdy znajdziesz którykolwiek z tych przypadków, poszukaj jego rodzeństwa, zanim zamkniesz zgłoszenie. Błędy kontroli dostępu rzadko są pojedyncze; podążają za wzorcami, które zespół kopiuje z handlera do handlera.

Jak to wygląda w route handlerze Next.js

Oto ten wzorzec w najczęstszej postaci. Handler uwierzytelnia użytkownika, co sprawia wrażenie bezpieczeństwa, a następnie ładuje rekord wyłącznie po jego identyfikatorze. Sprawdzenie sesji odpowiada na pytanie, kto wywołuje; nic nie odpowiada na pytanie, czy ten wywołujący może zobaczyć tę fakturę.

// app/api/invoices/[id]/route.ts  (podatne)
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;
  // Każdy zalogowany użytkownik może odczytać dowolną fakturę, zmieniając ID
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

Poprawka polega na tym, żeby własność stała się częścią samego zapytania, a nie osobnym krokiem, o którym można zapomnieć. Jeśli rekord nie należy do wywołującego, baza danych nie zwraca nic, a handler odpowiada kodem 404.

// app/api/invoices/[id]/route.ts  (poprawione)
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);
}

Zwracanie 404 zamiast 403 jest celowe. Kod 403 potwierdza, że rekord istnieje, co pozwala atakującemu wyliczać prawidłowe identyfikatory, nawet jeśli nie może ich odczytać. To samo dotyczy czasów odpowiedzi i komunikatów błędów: odpowiedź dla cudzego rekordu powinna być nie do odróżnienia od odpowiedzi dla rekordu, który nigdy nie istniał.

REST: endpointy, o których się zapomina

Zespoły zwykle zabezpieczają oczywisty GET po identyfikatorze. Błędy kryją się w pozostałych metodach i na obrzeżach API:

  • Handlery PATCH i DELETE skopiowane z handlera GET, zanim dodano do niego sprawdzenie własności.
  • Zagnieżdżone trasy, takie jak /projects/:projectId/tasks/:taskId, gdzie projekt jest sprawdzany, ale zadanie jest ładowane wyłącznie po taskId i może należeć do innego projektu.
  • Endpointy zbiorcze, które przyjmują tablicę identyfikatorów, a sprawdzają tylko pierwszy z nich.
  • Pobieranie plików i zadania eksportu, które często działają w osobnej usłudze z własnymi, słabszymi sprawdzeniami.
  • Payloady aktualizacji przyjmujące ownerId, organizationId lub role z ciała żądania i zapisujące je wprost do bazy danych (mass assignment).

GraphQL poszerza powierzchnię ataku

W GraphQL ten sam obiekt można osiągnąć wieloma ścieżkami. Sprawdzenie na poziomie zapytania invoice(id) nic nie da, jeśli ta sama faktura jest osiągalna także przez customer { invoices }, odczyt node(id) albo typ zwracany przez mutację. Każdy resolver zwracający obiekt jest punktem wejścia. Warstwy grupujące zapytania, takie jak DataLoader, dokładają kolejną pułapkę: loader kluczowany wyłącznie po identyfikatorze bez oporu zwróci rekordy dowolnemu odbiorcy, a jego pamięć podręczna może podać dane jednego użytkownika późniejszemu żądaniu, jeśli jest współdzielona między żądaniami.

Niezawodne podejście to autoryzacja w warstwie danych, którą wywołują resolvery, a nie w samych resolverach. Jeśli każda ścieżka do faktury przechodzi przez jedną funkcję, która przyjmuje odbiorcę i ogranicza zapytanie, dodanie nowego pola lub relacji nie może jej ominąć. Sprawdź też dane wejściowe mutacji: pole takie jak ownerId w typie wejściowym to zaproszenie do przepisywania rekordów. Wreszcie pamiętaj, że introspekcja i komunikaty błędów zdradzają Twój schemat, więc zakładaj, że atakujący znają każde pole i każdą relację, które udostępniasz.

Ten sam błąd w Pythonie

Kształt jest identyczny w FastAPI z SQLAlchemy. Wersja podatna wywołuje db.get(Document, doc_id); wersja poprawiona filtruje po właścicielu w tej samej instrukcji.

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),
):
    # Podatne: 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

Poprawki, które się bronią

Ogranicz każde zapytanie do właściciela lub najemcy

Umieść identyfikator użytkownika lub organizacji w klauzuli WHERE każdego odczytu i zapisu. Zamienia to autoryzację we właściwość zapytania, co łatwo zauważyć podczas przeglądu. W aplikacjach wielonajemcowych zabezpieczenia na poziomie wierszy w Postgresie mogą egzekwować granicę najemcy jako druga warstwa, więc zapomniany filtr zwróci pustkę zamiast wierszy innego klienta. To samo ograniczanie dotyczy zapisów. Aktualizacja powinna być pojedynczą instrukcją filtrowaną zarówno po identyfikatorze, jak i po właścicielu, na przykład updateMany z obydwoma warunkami i następującym po nim sprawdzeniem, że zmienił się dokładnie jeden wiersz, zamiast odczytu, sprawdzenia i osobnego zapisu, które mogą się ze sobą ścigać.

Scentralizuj decyzję

Rozproszone instrukcje if z czasem się rozjeżdżają. Niewielki zestaw funkcji pomocniczych, po jednej na zasób, trzyma regułę w jednym miejscu i sprawia, że handler bez wywołania takiej funkcji rzuca się w oczy.

// 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 },
  });
  // Domyślna odmowa: brak członkostwa i brak uprawnień wyglądają tak samo
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

Domyślnie odmawiaj

Nieznane role, brakujące członkostwa i nieoczekiwane akcje powinny wszystkie kończyć się odmową. We frameworkach z middleware wymagaj uwierzytelnienia dla wszystkiego i wyraźnie oznaczaj trasy publiczne, a nie odwrotnie. Nowa trasa powinna być zamknięta, dopóki ktoś nie zdecyduje inaczej. Nigdy nie bierz roli, najemcy ani identyfikatora użytkownika z ciała żądania lub nagłówka ustawionego przez klienta; za każdym razem wyprowadzaj je na serwerze ze zweryfikowanej sesji.

Losowe identyfikatory, takie jak UUID, warto stosować, ale nie są one poprawką. Identyfikatory wyciekają przez adresy URL, logi, udostępniane linki i nagłówki referrer. Traktuj je jako nieodgadnione tylko w tym sensie, że spowalniają wyliczanie, nigdy jako sprawdzenie dostępu.

Jak to testować

Testowanie IDOR jest proste i powtarzalne, dlatego warto je zautomatyzować, gdy raz przejdziesz je ręcznie. Zacznij od inwentaryzacji: wypisz każdą trasę, resolver i zadanie w tle, które przyjmuje identyfikator, łącznie z identyfikatorami ukrytymi w ciałach żądań, parametrach zapytania i nagłówkach.

  • Utwórz dwa konta, A i B, najlepiej w dwóch odrębnych organizacjach. Utwórz rekord jako A i zanotuj jego identyfikator.
  • Powtórz każde żądanie odwołujące się do tego identyfikatora z sesją B: GET, PATCH, DELETE, pobrania, eksporty oraz każde zapytanie i każdą mutację GraphQL, które go dotyczą.
  • Oczekuj 404 dla wszystkich. Każde 200 i każde 403 potwierdzające istnienie rekordu to znalezisko.
  • Zamień ręczne sprawdzenie w test integracyjny dla każdego zasobu, żeby nowy handler bez ograniczonego zapytania kończył się błędem w CI.
  • Przeszukaj kod pod kątem odczytów wyłącznie po kluczu głównym, takich jak findUnique({ where: { id } }) czy db.get(Model, id), i uzasadnij każdy z nich.

Przegląd kodu wychwytuje to, co umyka testom, bo brakujące sprawdzenie widać w źródle nawet wtedy, gdy nikt nie napisał testu dla tej trasy. CodeAuditAgent czyta publiczne repozytorium GitHub lub wklejony fragment kodu i zgłasza luki w kontroli dostępu wraz z CWE, zacytowaną linią, scenariuszem ataku i proponowaną poprawką, co jest szybkim sposobem na dodatkowe przejrzenie wszystkich handlerów naraz.

Krótka lista kontrolna

  • Każde zapytanie przyjmujące identyfikator od klienta filtruje także po użytkowniku lub najemcy wywołującego.
  • Autoryzacja żyje we wspólnych funkcjach pomocniczych lub w warstwie danych, a nie w kopiowanych instrukcjach if.
  • Nieznane przypadki kończą się odmową; trasy publiczne są wyraźnym wyjątkiem.
  • Operacje zapisu są sprawdzane równie starannie jak odczyty, łącznie z trasami zbiorczymi i zagnieżdżonymi.
  • Dla każdego zasobu istnieją testy na dwóch kontach i działają w CI.