تخطَّ إلى المحتوى
CodeAuditAgent
كل المقالات

حالات التسابق وأخطاء TOCTOU في تطبيقات الويب

كيف يقع الإنفاق المزدوج وإعادة استخدام القسائم وتجاوز الحدود حين تتسابق الطلبات، وكيف تصلحها بالقيود والتحديثات الذرّية والأقفال ومفاتيح عدم التكرار.

· قراءة في 7 دقائق · Lina Source LLC

معظم كود الويب مكتوب كما لو أن الطلبات تصل واحدًا تلو الآخر. اقرأ الرصيد، وتحقق من أنه كافٍ، واطرح، واحفظ. وباختبار يدوي، ينجح في كل مرة. أما إذا أُرسل عشرين مرة بالتوازي، فقد ينفق المال نفسه عشرين مرة.

هذه الأخطاء حالات تسابق (CWE-362)، وأكثر أشكالها شيوعًا هو الفارق بين وقت الفحص ووقت الاستخدام، أو TOCTOU (أي CWE-367): يفحص التطبيق شرطًا، ثم يتصرف بناءً عليه، ويتغير الشرط في الأثناء. ويسهل إغفالها في المراجعة لأن كل سطر يبدو صحيحًا بمفرده. فالخلل في الفجوة بين سطرين.

لماذا لا تنجو Node.js

ثمة اعتقاد شائع بأن بيئات التشغيل أحادية الخيط لا يمكن أن تصاب بحالات تسابق. صحيح أن JavaScript تشغّل استدعاءً واحدًا في كل مرة، لكن كل await نقطة يستطيع عندها طلب آخر أن يعمل. وقاعدة بياناتك مشتركة بين كل المثيلات وكل العمّال وكل الطلبات. وبين SELECT وUPDATE، قد يحدث أي شيء.

// Vulnerable: check-then-act across two awaits
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');
  }
  // Another request can pass the same check before this line runs
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

يقرأ طلبان رصيدًا قدره 100، ويجتاز كلاهما الفحص لسحب 100، ويكتب كلاهما 0. فيحصل المستخدم على 200 من حساب فيه 100. ولأن الكتابة الثانية تطمس الأولى بقيمة محسوبة من بيانات قديمة، لا تُظهر السجلات شيئًا غير معتاد. وأدوات ORM لا تغيّر هذا. فتحميل سجل إلى كائن وتعديل الكائن وحفظه هو نمط القراءة والتعديل والكتابة نفسه، وبالفجوة نفسها.

أين تظهر حالات التسابق

  • الأرصدة والاعتمادات والمحافظ: إنفاق الأموال نفسها مرتين.
  • القسائم وبطاقات الهدايا: استخدام رمز ذي استعمال واحد عدة مرات.
  • حدود الخطط والاستخدام: إنشاء مشاريع أو مقاعد أو استدعاءات واجهة برمجة أكثر مما تسمح به الخطة.
  • التسجيل والدعوات: إنشاء حسابين بالبريد نفسه، أو قبول دعوة واحدة مرتين.
  • التصويت والإعجابات والتقييمات: احتساب مستخدم واحد أكثر من مرة.
  • تدفقات الدفع والطلبات: تنفيذ طلب مرتين حين يصل webhook وإعادة توجيه معًا.

واستغلالها ليس صعبًا. فإرسال دفعة من الطلبات دفعة واحدة بـ Promise.all أو curl أو أداة وكيل يكفي لإصابة نوافذ من بضع ميلي ثانية، كما تجعل تقنيات مثل هجوم الحزمة الواحدة الطلبات تصل إلى الخادم في وقت يكاد يكون متزامنًا. افترض أنه إن وُجد تسابق، فثمة من يستطيع كسبه.

الإصلاح 1: دع قاعدة البيانات تفرض القاعدة

أقوى الإصلاحات تنقل القاعدة إلى قاعدة البيانات، حيث تُسلسَل الطلبات المتزامنة نيابة عنك. وأداتان تغطيان معظم الحالات: قيود التفرّد والتحديثات الشرطية الذرّية.

-- Single-use per user: the second insert fails, no matter the timing
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Global usage cap: check and increment in one statement
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Balance: the condition is evaluated against the current row
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Belt and braces: the balance can never go negative
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

ينجح التحديث الشرطي لأن قاعدة البيانات تقفل الصف أثناء تقييم عبارة WHERE. فإذا تسابق طلبان، أعاد الثاني فحص الشرط مقابل الصف الذي أودعه الأول. وإذا لم يعد أي صف، فقد فشل الشرط وتعيد خطأً. ولا توجد فجوة للاستغلال لأن الفحص والكتابة عبارة واحدة.

وفي كود التطبيق، تعامل مع مخالفة التفرّد بوصفها نتيجة طبيعية. ففي PostgreSQL تصل برمز الخطأ 23505؛ حوّلها إلى رسالة واضحة مثل «القسيمة مستخدمة من قبل» بدل خطأ 500.

ومعظم أدوات ORM تستطيع التعبير عن التحديث الشرطي. ففي Prisma، تعيد updateMany مع شرط الرصيد في عبارة where عددًا، والعدد صفر يعني فشل الفحص. أما findUnique متبوعة بتحديث منفصل فلا تمنحك الضمان نفسه، مهما كُتب الفحص بعناية.

الإصلاح 2: اقفل الصف بـ SELECT ... FOR UPDATE

أحيانًا يحتاج القرار أكثر من عبارة واحدة: قراءة عدة حقول، واستدعاء دالة تسعير، والكتابة في جدولين. عندئذٍ خذ قفل صف داخل معاملة. فـ SELECT ... FOR UPDATE تجعل أي معاملة أخرى تحاول قفل الصف نفسه تنتظر حتى تودع معاملتك أو تتراجع.

والمعاملة وحدها لا تكفي. فمستوى العزل الافتراضي في PostgreSQL هو READ COMMITTED، وعند ذلك المستوى لا يغيّر تغليف الكود المصاب السابق بـ BEGIN و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();
  }
}

وثمة تفصيلان مهمان. القفل لا ينفع إلا إذا أخذه كل مسار كود يغيّر الرصيد؛ فمسار واحد يحدّث دون قفل يعيد فتح التسابق. ويجب أن يستخدم التسلسل كله العميل نفسه: فتشغيل BEGIN على اتصال من المجمّع وSELECT على آخر لا يعطيك معاملة إطلاقًا. وتوفّر أدوات ORM النمط نفسه عبر المعاملات التفاعلية أو الاستعلامات الخام.

الإصلاح 3: أقفال استشارية لقواعد تمتد عبر الصفوف

لا تنفع أقفال الصفوف حين تكون القاعدة عن صفوف لم توجد بعد، مثل «لمستخدم الخطة المجانية ثلاثة مشاريع كحد أقصى». فقد يعدّ كل من طلبين مشروعين ويُدخل كل منهما ثالثًا. وتتيح لك الأقفال الاستشارية في PostgreSQL قفل مفتاح عشوائي، مثل معرّف المستخدم، طوال مدة المعاملة.

داخل المعاملة، استدعِ pg_advisory_xact_lock بمفتاح مشتق من المستخدم، ثم عُدّ وأدخِل. وتأخذ الدالة مفتاحًا صحيحًا بطول 64 بت، لذا اشتق عددًا صحيحًا ثابتًا من معرّف المستخدم، مثلًا بـ hashtext؛ والتصادم العرضي بين مستخدمين غير مرتبطين لا يكلّف إلا انتظارًا يسيرًا، لا صحةً. ويُحرَّر القفل تلقائيًا عند الإيداع أو التراجع. وثمة خيار آخر هو عزل SERIALIZABLE، الذي يجعل PostgreSQL يكتشف المعاملات المتعارضة ويجهض إحداها بالخطأ 40001؛ وهو يعمل جيدًا شريطة أن يعيد كودك محاولة المعاملات المجهضة.

أما ما لا ينفع فهو كائن إقصاء متبادل في الذاكرة أو خريطة Map من الأقفال في JavaScript. فذلك يغطي عملية واحدة. وما إن تشغّل مثيلين، أو دالتين بلا خادم، أو عاملًا في الخلفية، حتى يختفي القفل.

الإصلاح 4: مفاتيح عدم التكرار لإعادة المحاولة والإرسال المزدوج

ليست كل التكرارات هجمات: فالمستخدم ينقر نقرًا مزدوجًا، والعميل المحمول يعيد المحاولة بعد انتهاء المهلة، ومزوّد الدفع يعيد تسليم webhook. ومفتاح عدم التكرار يحوّل «افعل هذا» إلى «افعل هذا مرة واحدة». يولّد العميل مفتاحًا لكل عملية منطقية، ويسجّله الخادم بقيد تفرّد قبل أداء العمل.

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

-- Claim the key first; zero rows returned means another request owns it
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

إذا أعاد الإدخال صفًا، فنفّذ العملية في المعاملة نفسها وخزّن الاستجابة. وإذا لم يُعِد شيئًا، فابحث عن الاستجابة المخزّنة وأعدها، أو أعد 409 إذا كان الطلب الأول ما زال جاريًا. واقصر المفاتيح على المستخدم حتى لا يستطيع مستخدم إعادة تشغيل مفتاح غيره أو حجبه، وأنهِ صلاحيتها بعد مدة معقولة. وفي webhooks، معرّف حدث المزوّد هو المفتاح الطبيعي. وفكّر في تخزين بصمة لجسم الطلب مع المفتاح ورفض إعادة استخدام مفتاح مع جسم مختلف، حتى لا يتلقى خلل في العميل نتيجة عملية أخرى بصمت.

العثور على حالات التسابق في كودك

  • ابحث عن قراءة تتبعها كتابة للبيانات نفسها مع await بينهما.
  • ابحث عن أعداد تُقارن بحدود قبل عملية إدخال.
  • ابحث عن «ابحث ثم أنشئ إن لم يوجد» دون قيد تفرّد خلفها.
  • تأكد من أن كل كتابة لقيمة حساسة تمرّ عبر المسار المقفول أو الذرّي نفسه.
  • اكتب اختبارًا يطلق الطلب نفسه بالتوازي ويتحقق من بقاء الثابت صحيحًا.

والاختبار المتزامن هو أكثر الأدلة إقناعًا. شغّل العملية عشرين مرة بـ Promise.all مقابل قاعدة بيانات حقيقية، ثم تحقق من الرصيد أو عدد الاستخدامات أو عدد الصفوف. وإذا فشل قبل الإصلاح ونجح بعده، فقد أُغلق التسابق. وشغّله أكثر من مرة؛ فالتسابق الذي يفشل مرة من كل خمس تشغيلات يظل تسابقًا.

حالات التسابق من نوع الأخطاء التي يستطيع مراجع بالذكاء الاصطناعي إظهارها بقراءة التدفق كاملًا لا سطرًا واحدًا. وحين يبلّغ CodeAuditAgent عن إحداها، تحمل النتيجة معرّف CWE والفحص والكتابة المقتبسين وسيناريو استغلال يصف الطلبات المتوازية وتصحيحًا، يكون عادةً أحد الإصلاحات أعلاه. والقاعدة العامة واحدة في الحالتين: دع قاعدة البيانات تقرر، لأنها المكوّن الوحيد الذي يرى كل طلب.