İçeriğe atla
CodeAuditAgent
Tüm yazılar

Web Uygulamalarında Yarış Koşulları ve TOCTOU Hataları

Çifte harcama, kupon tekrarı ve limit aşımı istekler yarıştığında nasıl oluşur; kısıt, atomik güncelleme, kilit ve idempotency anahtarıyla nasıl düzeltilir?

· 7 dk okuma · Lina Source LLC

Web kodunun çoğu, istekler teker teker geliyormuş gibi yazılır. Bakiyeyi oku, yeterli olduğunu kontrol et, düş, kaydet. Elle test edildiğinde her seferinde çalışır. Paralel olarak yirmi kez gönderildiğinde ise aynı parayı yirmi kez harcayabilir.

Bu hatalar yarış koşullarıdır (CWE-362) ve en yaygın biçimi, kontrol zamanı ile kullanım zamanı arasındaki fark, yani TOCTOU'dur (CWE-367): uygulama bir koşulu kontrol eder, ardından ona göre davranır ve koşul arada değişir. İncelemede gözden kaçmaları kolaydır; çünkü her satır tek başına doğru görünür. Hata, iki satır arasındaki boşluktadır.

Node.js neden bağışık değil

Yaygın bir inanış, tek iş parçacıklı çalışma zamanlarında yarış koşulu olamayacağıdır. JavaScript aynı anda tek bir geri çağırma çalıştırır, ancak her await başka bir isteğin çalışabileceği bir noktadır. Veritabanınız tüm örnekler, tüm işçiler ve tüm istekler tarafından paylaşılır. SELECT ile UPDATE arasında her şey olabilir.

// Güvensiz: iki await arasına yayılan kontrol-sonra-davran kalıbı
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');
  }
  // Başka bir istek, bu satır çalışmadan önce aynı kontrolü geçebilir
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

İki istek 100 bakiyesini okur, ikisi de 100'lük bir çekim için kontrolü geçer ve ikisi de 0 yazar. Kullanıcı, 100 tutan bir hesaptan 200 çekmiş olur. İkinci yazma işlemi birincisinin üzerine bayat veriden hesaplanmış bir değerle yazdığı için loglarda olağandışı bir şey görünmez. ORM'ler bunu değiştirmez. Bir kaydı nesneye yükleyip nesneyi değiştirmek ve kaydetmek de aynı oku-değiştir-yaz kalıbıdır ve aynı boşluğu taşır.

Yarışlar nerede ortaya çıkar

  • Bakiyeler, krediler ve cüzdanlar: aynı paranın iki kez harcanması.
  • Kuponlar ve hediye kartları: tek kullanımlık bir kodun birkaç kez kullanılması.
  • Plan ve kullanım limitleri: planın izin verdiğinden fazla proje, koltuk veya API çağrısı oluşturulması.
  • Kayıt ve davetler: aynı e-postayla iki hesap açılması ya da tek bir davetin iki kez kabul edilmesi.
  • Oylama, beğeni ve puanlamalar: bir kullanıcının birden fazla kez sayılması.
  • Ödeme ve sipariş akışları: bir webhook ile bir yönlendirme aynı anda geldiğinde siparişin iki kez karşılanması.

Bunları istismar etmek zor değildir. Promise.all, curl veya bir proxy aracıyla aynı anda bir yığın istek göndermek, birkaç milisaniyelik pencereleri yakalamaya yeter; single-packet attack gibi teknikler ise isteklerin sunucuya neredeyse aynı anda ulaşmasını sağlar. Bir yarış varsa birilerinin onu kazanabileceğini varsayın.

Çözüm 1: kuralı veritabanına uygulatın

En güçlü çözümler kuralı, eşzamanlı isteklerin sizin için sıraya konduğu veritabanına taşır. İki araç vakaların çoğunu karşılar: benzersizlik kısıtları ve atomik koşullu güncellemeler.

-- Kullanıcı başına tek kullanım: zamanlama ne olursa olsun ikinci ekleme başarısız olur
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Genel kullanım üst sınırı: kontrol ve artırma tek ifadede
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Bakiye: koşul güncel satıra karşı değerlendirilir
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Ek güvence: bakiye asla negatif olamaz
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

Koşullu UPDATE işe yarar; çünkü veritabanı WHERE ifadesini değerlendirirken satırı kilitler. İki istek yarışırsa ikincisi, koşulu birincinin commit ettiği satıra karşı yeniden kontrol eder. Geriye satır dönmezse koşul sağlanmamıştır ve siz bir hata döndürürsünüz. Kontrol ile yazma aynı ifade olduğundan istismar edilecek bir boşluk kalmaz.

Uygulama kodunda benzersizlik ihlalini normal bir sonuç olarak ele alın. PostgreSQL'de bu, 23505 hata koduyla gelir; onu bir 500 yerine “kupon zaten kullanılmış” gibi net bir mesaja eşleyin.

ORM'lerin çoğu koşullu güncellemeyi ifade edebilir. Prisma'da, where ifadesinde bakiye koşulunu taşıyan updateMany bir sayı döndürür ve sayının sıfır olması kontrolün başarısız olduğu anlamına gelir. Önce findUnique, ardından ayrı bir update çağırmak, kontrol ne kadar dikkatli yazılırsa yazılsın size aynı garantiyi vermez.

Çözüm 2: satırı SELECT ... FOR UPDATE ile kilitleyin

Bazen karar tek bir ifadeden fazlasını gerektirir: birkaç alanı okumak, bir fiyatlandırma fonksiyonu çağırmak, iki tabloya yazmak. O zaman bir işlem içinde satır kilidi alın. SELECT ... FOR UPDATE, aynı satırı kilitlemeye çalışan diğer işlemleri sizinki commit veya rollback edene kadar bekletir.

Tek başına bir işlem yeterli değildir. PostgreSQL'in varsayılan yalıtım düzeyi READ COMMITTED'dir ve bu düzeyde yukarıdaki güvensiz kodu BEGIN ile COMMIT arasına almak hiçbir şeyi değiştirmez: iki işlem de aynı bakiyeyi okur, ikisi de kontrolü geçer ve iki yazma da başarılı olur. İkinci işlemi bekletip ardından commit edilmiş değeri okumaya zorlayan şey kilittir.

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();
  }
}

İki ayrıntı önemlidir. Kilit yalnızca bakiyeyi değiştiren her kod yolu onu aldığında işe yarar; kilit almadan güncelleyen tek bir yol yarışı yeniden açar. Ayrıca tüm dizi aynı istemciyi kullanmalıdır: BEGIN'i havuzdaki bir bağlantıda, SELECT'i başka bir bağlantıda çalıştırmak size hiçbir işlem sağlamaz. ORM'ler aynı kalıbı etkileşimli işlemler veya ham sorgular üzerinden sunar.

Çözüm 3: satırları aşan kurallar için advisory kilitler

Kural, “ücretsiz plandaki bir kullanıcının en fazla üç projesi olabilir” gibi henüz var olmayan satırlarla ilgiliyse satır kilitleri yardımcı olmaz. İki istek de iki proje sayıp her biri üçüncüyü ekleyebilir. PostgreSQL advisory kilitleri, kullanıcı ID'si gibi rastgele bir anahtarı bir işlem süresince kilitlemenizi sağlar.

İşlemin içinde, kullanıcıdan türetilmiş bir anahtarla pg_advisory_xact_lock çağırın, ardından sayın ve ekleyin. Fonksiyon 64 bitlik bir tamsayı anahtar aldığından, kullanıcı ID'sinden kararlı bir tamsayı türetin; örneğin hashtext ile. İlgisiz kullanıcılar arasında ara sıra yaşanacak bir çakışmanın bedeli yalnızca biraz beklemektir, asla doğruluk değil. Kilit, commit veya rollback anında otomatik olarak bırakılır. Bir diğer seçenek, PostgreSQL'in çakışan işlemleri tespit edip birini 40001 hatasıyla iptal etmesini sağlayan SERIALIZABLE yalıtımıdır; kodunuz iptal edilen işlemleri yeniden denediği sürece bu da iyi çalışır.

İşe yaramayan şey, bellek içi bir mutex ya da JavaScript'te bir kilit Map'idir. Bu yalnızca tek bir süreci kapsar. İki örnek, iki sunucusuz fonksiyon ya da bir arka plan işçisi çalıştırdığınız anda kilit ortadan kalkar.

Çözüm 4: yeniden denemeler ve çift gönderimler için idempotency anahtarları

Bazı tekrarlar saldırı değildir: kullanıcı çift tıklar, mobil istemci zaman aşımından sonra yeniden dener, bir ödeme sağlayıcısı webhook'u yeniden gönderir. Bir idempotency anahtarı, “bunu yap” isteğini “bunu bir kez yap” hâline getirir. İstemci her mantıksal işlem için bir anahtar üretir ve sunucu, işi yapmadan önce bu anahtarı benzersizlik kısıtıyla kaydeder.

-- Şema
CREATE TABLE idempotency_keys (
  key         text PRIMARY KEY,
  user_id     uuid NOT NULL,
  response    jsonb,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Önce anahtarı sahiplenin; sıfır satır dönmesi anahtarın başka bir isteğe ait olduğunu gösterir
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Ekleme bir satır döndürürse işlemi aynı transaction içinde çalıştırın ve yanıtı saklayın. Hiçbir şey döndürmezse saklanan yanıtı bulup döndürün ya da ilk istek hâlâ sürüyorsa 409 döndürün. Anahtarları kullanıcıyla sınırlandırın; böylece bir kullanıcı başkasının anahtarını tekrar oynatamaz veya bloke edemez ve anahtarları makul bir süre sonunda geçersiz kılın. Webhook'lar için sağlayıcının olay kimliği doğal anahtardır. Anahtarla birlikte istek gövdesinin bir hash'ini saklamayı ve farklı bir gövdeyle aynı anahtarın kullanılmasını reddetmeyi düşünün; böylece bir istemci hatası, sessizce başka bir işlemin sonucunu almanıza yol açmaz.

Kodunuzdaki yarışları bulmak

  • Aynı verinin okunmasının ardından arada bir await bulunan bir yazma işlemi arayın.
  • Bir eklemeden önce limitlerle karşılaştırılan sayımları arayın.
  • Arkasında benzersizlik kısıtı olmayan “bul, yoksa oluştur” kalıplarını arayın.
  • Hassas bir değere yapılan her yazmanın aynı kilitli veya atomik yoldan geçtiğini kontrol edin.
  • Aynı isteği eşzamanlı olarak gönderen ve değişmezin hâlâ geçerli olduğunu doğrulayan bir test yazın.

En ikna edici kanıt eşzamanlı testtir. İşlemi gerçek bir veritabanına karşı Promise.all ile yirmi kez çalıştırın, ardından bakiyeyi, kullanım sayısını veya satır sayısını doğrulayın. Düzeltmeden önce başarısız olup sonrasında geçiyorsa yarış kapanmıştır. Testi birden fazla kez çalıştırın; beş çalıştırmanın birinde başarısız olan bir yarış da yarıştır.

Yarış koşulları, bir yapay zekâ inceleyicinin tek bir satırı değil akışın tamamını okuyarak ortaya çıkarabileceği türden hatalardır. CodeAuditAgent böyle bir hatayı işaretlediğinde bulgu; CWE'yi, alıntılanmış kontrol ve yazma satırlarını, paralel istekleri anlatan bir istismar senaryosunu ve genellikle yukarıdaki çözümlerden biri olan bir yamayı içerir. Genel kural her durumda aynıdır: kararı veritabanına bırakın, çünkü her isteği gören tek bileşen odur.