Wyścigi i błędy TOCTOU w aplikacjach webowych
Skąd biorą się podwójne wydatki, wielokrotne kupony i obejścia limitów, gdy żądania się ścigają, i jak to naprawić: ograniczenia, blokady, klucze idempotencji.
· 7 min czytania · Lina Source LLC
Większość kodu webowego jest pisana tak, jakby żądania przychodziły pojedynczo. Odczytaj saldo, sprawdź, czy wystarcza, odejmij, zapisz. Przetestowany ręcznie, działa za każdym razem. Wysłany dwadzieścia razy równolegle potrafi wydać te same pieniądze dwadzieścia razy.
To są wyścigi (CWE-362), a ich najczęstsza postać to czas sprawdzenia kontra czas użycia, czyli TOCTOU (CWE-367): aplikacja sprawdza warunek, potem na jego podstawie działa, a warunek zmienia się w międzyczasie. Łatwo je przeoczyć podczas przeglądu, bo każda linia z osobna wygląda poprawnie. Błąd tkwi w szczelinie między dwiema liniami.
Dlaczego Node.js nie jest odporny
Powszechnie sądzi się, że jednowątkowe środowiska uruchomieniowe nie mogą mieć wyścigów. JavaScript wykonuje jedno wywołanie zwrotne naraz, ale każde await to punkt, w którym może wykonać się inne żądanie. Twoja baza danych jest współdzielona przez wszystkie instancje, wszystkie procesy robocze i wszystkie żądania. Między SELECT a UPDATE może stać się wszystko.
// Podatne: sprawdź, a potem działaj, w poprzek dwóch await
export async function withdraw(userId: string, amount: number) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < amount) {
throw new Error('Insufficient funds');
}
// Inne żądanie może przejść to samo sprawdzenie, zanim wykona się ta linia
await db.account.update({
where: { userId },
data: { balance: account.balance - amount },
});
}Dwa żądania odczytują saldo 100, oba przechodzą sprawdzenie dla wypłaty 100 i oba zapisują 0. Użytkownik wyciągnął 200 z konta, na którym było 100. Ponieważ drugi zapis nadpisuje pierwszy wartością policzoną ze starych danych, logi nie pokazują niczego niezwykłego. ORM-y tego nie zmieniają. Wczytanie rekordu do obiektu, zmodyfikowanie obiektu i zapisanie go to ten sam wzorzec odczyt-modyfikacja-zapis, z tą samą szczeliną.
Gdzie pojawiają się wyścigi
- Salda, kredyty i portfele: wydawanie tych samych środków dwa razy.
- Kupony i karty podarunkowe: wielokrotne wykorzystanie kodu jednorazowego użytku.
- Limity planu i zużycia: tworzenie większej liczby projektów, miejsc czy wywołań API, niż pozwala plan.
- Rejestracja i zaproszenia: utworzenie dwóch kont z tym samym adresem e-mail albo dwukrotne przyjęcie jednego zaproszenia.
- Głosowanie, polubienia i oceny: policzenie jednego użytkownika więcej niż raz.
- Procesy płatności i zamówień: dwukrotna realizacja zamówienia, gdy webhook i przekierowanie przychodzą jednocześnie.
Wykorzystanie ich nie jest trudne. Wysłanie paczki żądań naraz przez Promise.all, curl albo narzędzie proxy wystarczy, żeby trafić w okno kilku milisekund, a techniki takie jak atak pojedynczym pakietem sprawiają, że żądania docierają do serwera niemal jednocześnie. Zakładaj, że jeśli wyścig istnieje, ktoś potrafi go wygrać.
Poprawka 1: niech regułę egzekwuje baza danych
Najmocniejsze poprawki przenoszą regułę do bazy danych, gdzie współbieżne żądania są za Ciebie szeregowane. Dwa narzędzia obejmują większość przypadków: ograniczenia unikalności i atomowe aktualizacje warunkowe.
-- Jednorazowo na użytkownika: drugi insert zawiedzie, niezależnie od czasu
CREATE UNIQUE INDEX coupon_redemptions_once
ON coupon_redemptions (coupon_id, user_id);
-- Globalny limit użyć: sprawdzenie i zwiększenie w jednej instrukcji
UPDATE coupons
SET uses = uses + 1
WHERE id = $1
AND uses < max_uses
RETURNING id;
-- Saldo: warunek jest oceniany względem bieżącego wiersza
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2
AND balance >= $1
RETURNING balance;
-- Dla pewności: saldo nigdy nie może zejść poniżej zera
ALTER TABLE accounts
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);Warunkowy UPDATE działa, bo baza danych blokuje wiersz, gdy ocenia klauzulę WHERE. Jeśli dwa żądania się ścigają, drugie ponownie sprawdza warunek wobec wiersza zatwierdzonego przez pierwsze. Jeśli nie wróci żaden wiersz, warunek zawiódł i zwracasz błąd. Nie ma szczeliny do wykorzystania, bo sprawdzenie i zapis to ta sama instrukcja.
W kodzie aplikacji traktuj naruszenie unikalności jako normalny wynik. W PostgreSQL przychodzi ono jako kod błędu 23505; zamapuj je na czytelny komunikat, na przykład „kupon został już wykorzystany”, zamiast na błąd 500.
Większość ORM-ów potrafi wyrazić aktualizację warunkową. W Prismie updateMany z warunkiem salda w klauzuli where zwraca licznik, a licznik równy zero oznacza, że sprawdzenie zawiodło. findUnique, po którym następuje osobny update, nie daje tej samej gwarancji, niezależnie od tego, jak starannie napisano sprawdzenie.
Poprawka 2: zablokuj wiersz przez SELECT ... FOR UPDATE
Czasem decyzja wymaga więcej niż jednej instrukcji: odczytania kilku pól, wywołania funkcji wyceniającej, zapisu do dwóch tabel. Wtedy załóż blokadę wiersza wewnątrz transakcji. SELECT ... FOR UPDATE sprawia, że każda inna transakcja próbująca zablokować ten sam wiersz czeka, aż Twoja zostanie zatwierdzona lub wycofana.
Sama transakcja nie wystarczy. Domyślnym poziomem izolacji w PostgreSQL jest READ COMMITTED, a na tym poziomie opakowanie wcześniejszego podatnego kodu w BEGIN i COMMIT niczego nie zmienia: obie transakcje odczytują to samo saldo, obie przechodzą sprawdzenie i oba zapisy się udają. To blokada zmusza drugą transakcję do czekania, a następnie do odczytania zatwierdzonej wartości.
import { Pool } from 'pg';
const pool = new Pool();
export async function purchase(userId: string, itemId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[userId],
);
const item = await client.query(
'SELECT price FROM items WHERE id = $1',
[itemId],
);
if (rows.length === 0 || item.rows.length === 0) {
throw new Error('Not found');
}
const price = Number(item.rows[0].price);
if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[price, userId],
);
await client.query(
'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
[userId, itemId, price],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}Liczą się dwa szczegóły. Blokada pomaga tylko wtedy, gdy zakłada ją każda ścieżka kodu zmieniająca saldo; jedna ścieżka aktualizująca bez blokady otwiera wyścig na nowo. Poza tym cała sekwencja musi używać tego samego klienta: uruchomienie BEGIN na jednym połączeniu z puli, a SELECT na innym nie daje Ci żadnej transakcji. ORM-y oferują ten sam wzorzec przez transakcje interaktywne lub surowe zapytania.
Poprawka 3: blokady doradcze dla reguł obejmujących wiele wierszy
Blokady wierszy nie pomagają, gdy reguła dotyczy wierszy, które jeszcze nie istnieją, na przykład „użytkownik planu darmowego może mieć najwyżej trzy projekty”. Dwa żądania mogą naliczyć po dwa projekty i wstawić trzeci. Blokady doradcze w PostgreSQL pozwalają zablokować dowolny klucz, na przykład identyfikator użytkownika, na czas trwania transakcji.
Wewnątrz transakcji wywołaj pg_advisory_xact_lock z kluczem wyprowadzonym z użytkownika, a potem policz i wstaw. Funkcja przyjmuje 64-bitowy klucz całkowity, więc wyprowadź stabilną liczbę z identyfikatora użytkownika, na przykład przez hashtext; sporadyczna kolizja między niezwiązanymi użytkownikami kosztuje tylko odrobinę czekania, nigdy poprawność. Blokada jest zwalniana automatycznie przy zatwierdzeniu lub wycofaniu. Inną opcją jest izolacja SERIALIZABLE, przy której PostgreSQL wykrywa konfliktujące transakcje i przerywa jedną z nich błędem 40001; działa to dobrze, pod warunkiem że Twój kod ponawia przerwane transakcje.
Nie zadziała natomiast muteks w pamięci ani JavaScriptowa mapa blokad. Obejmuje ona jeden proces. W chwili, gdy uruchomisz dwie instancje, dwie funkcje serverless albo workera w tle, blokady nie ma.
Poprawka 4: klucze idempotencji przy ponowieniach i podwójnych wysyłkach
Część duplikatów to nie ataki: użytkownik klika dwa razy, klient mobilny ponawia po przekroczeniu limitu czasu, dostawca płatności dostarcza webhook ponownie. Klucz idempotencji zamienia „zrób to” w „zrób to raz”. Klient generuje klucz dla każdej logicznej operacji, a serwer zapisuje go z ograniczeniem unikalności przed wykonaniem pracy.
-- Schemat
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Najpierw zajmij klucz; zero zwróconych wierszy oznacza, że ma go inne żądanie
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;Jeśli insert zwróci wiersz, wykonaj operację w tej samej transakcji i zapisz odpowiedź. Jeśli nie zwróci nic, odszukaj zapisaną odpowiedź i zwróć ją albo zwróć 409, jeśli pierwsze żądanie wciąż trwa. Ogranicz zasięg kluczy do użytkownika, żeby jeden użytkownik nie mógł odtworzyć ani zablokować klucza innego, i wygaszaj je po rozsądnym czasie. Dla webhooków naturalnym kluczem jest identyfikator zdarzenia u dostawcy. Rozważ zapisywanie razem z kluczem hasza ciała żądania i odrzucanie ponownego użycia klucza z innym ciałem, żeby błąd klienta nie odebrał po cichu wyniku innej operacji.
Znajdowanie wyścigów w swoim kodzie
- Szukaj odczytu, po którym następuje zapis tych samych danych, z await pomiędzy nimi.
- Szukaj liczników porównywanych z limitami przed wstawieniem.
- Szukaj wzorca „znajdź, a jeśli nie ma, utwórz” bez stojącego za nim ograniczenia unikalności.
- Sprawdź, czy każdy zapis wrażliwej wartości przechodzi tą samą zablokowaną lub atomową ścieżką.
- Napisz test, który wysyła to samo żądanie współbieżnie i sprawdza, czy niezmiennik nadal się utrzymuje.
Test współbieżny to najbardziej przekonujący dowód. Uruchom operację dwadzieścia razy przez Promise.all na prawdziwej bazie danych, a potem sprawdź saldo, liczbę wykorzystań lub liczbę wierszy. Jeśli zawiedzie przed poprawką i przejdzie po niej, wyścig jest zamknięty. Uruchom go więcej niż raz; wyścig, który wywala się w jednym przebiegu na pięć, nadal jest wyścigiem.
Wyścigi to rodzaj błędu, który recenzent AI może wychwycić, czytając cały przepływ, a nie pojedynczą linię. Gdy CodeAuditAgent go zgłasza, znalezisko zawiera CWE, zacytowane sprawdzenie i zapis, scenariusz ataku opisujący równoległe żądania oraz poprawkę, zwykle jedną z powyższych. Ogólna zasada jest w obu przypadkach ta sama: niech decyduje baza danych, bo to jedyny komponent, który widzi każde żądanie.