Lewati ke konten
CodeAuditAgent
Semua artikel

Race Condition dan Bug TOCTOU di Aplikasi Web

Bagaimana double-spend, kupon dipakai ulang, dan batas ditembus saat permintaan berlomba, serta cara memperbaikinya dengan constraint, update atomik, dan lock.

· 7 menit baca · Lina Source LLC

Sebagian besar kode web ditulis seolah-olah permintaan datang satu per satu. Baca saldonya, pastikan cukup, kurangi, simpan. Diuji dengan tangan, ia berhasil setiap kali. Dikirim dua puluh kali secara paralel, ia bisa membelanjakan uang yang sama dua puluh kali.

Bug semacam ini adalah race condition (CWE-362), dan bentuk yang paling umum adalah time-of-check to time-of-use, atau TOCTOU (CWE-367): aplikasi memeriksa sebuah kondisi, lalu bertindak atasnya, dan kondisinya berubah di antara keduanya. Bug ini mudah terlewat saat review karena setiap baris terlihat benar jika berdiri sendiri. Bug-nya ada di celah antara dua baris.

Mengapa Node.js tidak kebal

Keyakinan yang umum adalah runtime berutas tunggal tidak mungkin mengalami race condition. JavaScript menjalankan satu callback pada satu waktu, tetapi setiap await adalah titik di mana permintaan lain bisa berjalan. Database Anda dipakai bersama oleh semua instance, semua worker, dan semua permintaan. Di antara SELECT dan UPDATE, apa pun bisa terjadi.

// Rentan: cek-lalu-aksi yang terbelah oleh dua 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');
  }
  // Permintaan lain bisa lolos cek yang sama sebelum baris ini berjalan
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Dua permintaan membaca saldo 100, keduanya lolos cek untuk penarikan 100, dan keduanya menulis 0. Pengguna itu mendapat 200 dari rekening yang berisi 100. Karena penulisan kedua menimpa yang pertama dengan nilai yang dihitung dari data basi, log tidak menunjukkan sesuatu yang aneh. ORM tidak mengubah ini. Memuat sebuah record menjadi objek, mengubah objeknya, lalu menyimpannya adalah pola baca-ubah-tulis yang sama, dengan celah yang sama.

Di mana race muncul

  • Saldo, kredit, dan dompet: membelanjakan dana yang sama dua kali.
  • Kupon dan kartu hadiah: menukarkan kode sekali pakai beberapa kali.
  • Batas paket dan pemakaian: membuat lebih banyak proyek, kursi, atau pemanggilan API daripada yang diizinkan paket.
  • Pendaftaran dan undangan: membuat dua akun dengan email yang sama, atau menerima satu undangan dua kali.
  • Voting, suka, dan penilaian: menghitung satu pengguna lebih dari sekali.
  • Alur pembayaran dan pesanan: memenuhi satu pesanan dua kali ketika webhook dan redirect tiba bersamaan.

Mengeksploitasinya tidak sulit. Mengirim sekumpulan permintaan sekaligus dengan Promise.all, curl, atau sebuah perkakas proksi sudah cukup untuk mengenai jendela selebar beberapa milidetik, dan teknik seperti single-packet attack membuat permintaan tiba di server nyaris bersamaan. Asumsikan bahwa jika sebuah race ada, seseorang bisa memenanginya.

Perbaikan 1: biarkan database yang menegakkan aturan

Perbaikan yang paling kuat memindahkan aturannya ke dalam database, tempat permintaan yang bersamaan diserialisasi untuk Anda. Dua perkakas mencakup sebagian besar kasus: unique constraint dan update kondisional yang atomik.

-- Sekali pakai per pengguna: insert kedua gagal, apa pun waktunya
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Batas pemakaian global: cek dan naikkan dalam satu statement
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Saldo: kondisi dievaluasi terhadap baris yang berlaku saat itu
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Pengaman ganda: saldo tidak akan pernah bisa menjadi negatif
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

UPDATE kondisional itu berhasil karena database mengunci barisnya selama mengevaluasi klausa WHERE. Jika dua permintaan berlomba, yang kedua memeriksa ulang kondisinya terhadap baris yang sudah di-commit permintaan pertama. Jika tidak ada baris yang kembali, kondisinya gagal dan Anda mengembalikan error. Tidak ada celah untuk dieksploitasi karena cek dan penulisannya adalah statement yang sama.

Di kode aplikasi, perlakukan pelanggaran unique sebagai hasil yang normal. Di PostgreSQL ia datang sebagai kode error 23505; petakan menjadi pesan yang jelas seperti 'kupon sudah dipakai' alih-alih sebuah 500.

Sebagian besar ORM bisa mengekspresikan update kondisional itu. Dengan Prisma, updateMany dengan kondisi saldo di klausa where-nya mengembalikan sebuah hitungan, dan hitungan nol berarti ceknya gagal. findUnique yang diikuti update terpisah tidak memberi Anda jaminan yang sama, sehati-hati apa pun cek itu ditulis.

Perbaikan 2: kunci barisnya dengan SELECT ... FOR UPDATE

Kadang keputusannya butuh lebih dari satu statement: membaca beberapa field, memanggil fungsi penetapan harga, menulis ke dua tabel. Kalau begitu, ambil row lock di dalam sebuah transaksi. SELECT ... FOR UPDATE membuat transaksi lain yang mencoba mengunci baris yang sama harus menunggu sampai transaksi Anda di-commit atau di-rollback.

Transaksi saja tidak cukup. Tingkat isolasi bawaan PostgreSQL adalah READ COMMITTED, dan pada tingkat itu membungkus kode rentan sebelumnya dengan BEGIN dan COMMIT tidak mengubah apa pun: kedua transaksi membaca saldo yang sama, keduanya lolos ceknya, dan kedua penulisannya berhasil. Lock itulah yang memaksa transaksi kedua menunggu lalu membaca nilai yang sudah di-commit.

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

Dua detail penting. Lock itu hanya menolong jika setiap jalur kode yang mengubah saldo juga mengambilnya; satu jalur yang melakukan update tanpa mengunci membuka kembali race-nya. Dan seluruh rangkaian itu harus memakai client yang sama: menjalankan BEGIN pada satu koneksi dari pool dan SELECT pada koneksi lain sama sekali tidak memberi Anda transaksi. ORM menawarkan pola yang sama melalui transaksi interaktif atau query mentah.

Perbaikan 3: advisory lock untuk aturan yang melintasi banyak baris

Row lock tidak menolong ketika aturannya menyangkut baris yang belum ada, seperti 'pengguna paket gratis paling banyak boleh punya tiga proyek'. Dua permintaan bisa masing-masing menghitung dua proyek lalu masing-masing menyisipkan yang ketiga. Advisory lock di PostgreSQL memungkinkan Anda mengunci kunci sembarang, seperti ID pengguna, selama durasi sebuah transaksi.

Di dalam transaksi, panggil pg_advisory_xact_lock dengan kunci yang diturunkan dari pengguna, lalu hitung dan sisipkan. Fungsi itu menerima kunci integer 64-bit, jadi turunkan integer yang stabil dari ID pengguna, misalnya dengan hashtext; tabrakan sesekali antara pengguna yang tidak berhubungan hanya berbiaya sedikit waktu tunggu, tidak pernah mengorbankan kebenaran. Lock-nya dilepaskan otomatis saat commit atau rollback. Opsi lain adalah isolasi SERIALIZABLE, yang membuat PostgreSQL mendeteksi transaksi yang berkonflik lalu membatalkan salah satunya dengan error 40001; itu bekerja dengan baik, asalkan kode Anda mencoba ulang transaksi yang dibatalkan.

Yang tidak berhasil adalah mutex di memori atau Map lock di JavaScript. Itu hanya mencakup satu proses. Begitu Anda menjalankan dua instance, dua fungsi serverless, atau sebuah worker latar belakang, lock itu lenyap.

Perbaikan 4: kunci idempotensi untuk retry dan kiriman ganda

Sebagian duplikasi bukan serangan: pengguna mengeklik dua kali, klien mobile mencoba ulang setelah timeout, penyedia pembayaran mengirim ulang sebuah webhook. Kunci idempotensi mengubah 'lakukan ini' menjadi 'lakukan ini sekali saja'. Klien membangkitkan satu kunci per operasi logis, dan server mencatatnya dengan unique constraint sebelum mengerjakan pekerjaannya.

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

-- Klaim kuncinya lebih dulu; nol baris berarti permintaan lain yang memilikinya
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Jika insert mengembalikan sebuah baris, jalankan operasinya di transaksi yang sama dan simpan responsnya. Jika ia tidak mengembalikan apa pun, cari respons yang tersimpan lalu kembalikan, atau kembalikan 409 jika permintaan pertama masih berjalan. Batasi kunci ke masing-masing pengguna agar satu pengguna tidak bisa memutar ulang atau memblokir kunci pengguna lain, dan kedaluwarsakan setelah jangka waktu yang masuk akal. Untuk webhook, ID event dari penyedia adalah kunci yang paling alami. Pertimbangkan menyimpan hash dari body permintaan bersama kuncinya lalu menolak penggunaan ulang kunci dengan body berbeda, sehingga bug di sisi klien tidak bisa diam-diam menerima hasil dari operasi lain.

Menemukan race di kode Anda

  • Cari pembacaan yang diikuti penulisan atas data yang sama dengan sebuah await di antaranya.
  • Cari hitungan yang dibandingkan dengan batas sebelum sebuah insert.
  • Cari pola 'cari, lalu buat jika belum ada' tanpa unique constraint di belakangnya.
  • Pastikan setiap penulisan ke nilai sensitif melewati jalur terkunci atau atomik yang sama.
  • Tulis tes yang menembakkan permintaan yang sama secara bersamaan dan memastikan invariannya masih berlaku.

Tes bersamaan itu adalah bukti yang paling meyakinkan. Jalankan operasinya dua puluh kali dengan Promise.all terhadap database sungguhan, lalu periksa saldonya, jumlah penukaran, atau jumlah barisnya. Jika ia gagal sebelum perbaikan dan lolos sesudahnya, race-nya sudah ditutup. Jalankan lebih dari sekali; race yang gagal satu kali dari lima tetaplah sebuah race.

Race condition adalah jenis bug yang bisa diangkat oleh reviewer AI dengan membaca seluruh alurnya alih-alih satu baris saja. Ketika CodeAuditAgent menandai satu di antaranya, temuannya membawa CWE, cek dan penulisan yang dikutip, skenario eksploit yang menggambarkan permintaan paralelnya, dan sebuah patch, biasanya salah satu perbaikan di atas. Aturan umumnya sama saja: biarkan database yang memutuskan, karena ia adalah satu-satunya komponen yang melihat setiap permintaan.