सामग्री पर जाएँ
CodeAuditAgent
सभी लेख

वेब एप्लिकेशन में Race Conditions और TOCTOU बग

Requests race करने पर double-spend, coupon reuse और limit bypass कैसे होते हैं, और constraints, atomic updates, locks व idempotency keys से इनका फ़िक्स।

· 7 मिनट का लेख · Lina Source LLC

ज़्यादातर वेब कोड ऐसे लिखा जाता है मानो requests एक-एक करके आती हों। बैलेंस पढ़ो, जाँचो कि काफ़ी है, घटाओ, सेव करो। हाथ से टेस्ट करने पर यह हर बार काम करता है। बीस बार समानांतर में भेजने पर यह वही पैसा बीस बार ख़र्च कर सकता है।

ये बग race conditions (CWE-362) हैं, और सबसे आम रूप है time-of-check to time-of-use, यानी TOCTOU (CWE-367): एप्लिकेशन कोई शर्त जाँचता है, फिर उस पर काम करता है, और बीच में शर्त बदल जाती है। रिव्यू में इन्हें चूकना आसान है क्योंकि हर लाइन अपने आप में सही दिखती है। बग दो लाइनों के बीच के अंतराल में है।

Node.js इससे अछूता क्यों नहीं

एक आम धारणा है कि single-threaded runtimes में race conditions नहीं हो सकतीं। JavaScript एक बार में एक callback चलाता है, पर हर await एक ऐसा बिंदु है जहाँ कोई दूसरी request चल सकती है। आपका डेटाबेस सभी instances, सभी workers और सभी requests के बीच साझा है। SELECT और UPDATE के बीच कुछ भी हो सकता है।

// Vulnerable: दो awaits के आर-पार check-then-act
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');
  }
  // इस लाइन के चलने से पहले कोई दूसरी request वही चेक पास कर सकती है
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

दो requests 100 का बैलेंस पढ़ती हैं, दोनों 100 की निकासी का चेक पास कर लेती हैं, और दोनों 0 लिख देती हैं। यूज़र ने 100 वाले अकाउंट से 200 निकाल लिए। चूँकि दूसरा write पहले को बासी डेटा से गिनी गई value से ओवरराइट करता है, logs में कुछ असामान्य नहीं दिखता। ORM इसे नहीं बदलते। किसी रिकॉर्ड को object में लोड करना, object बदलना और उसे सेव करना वही read-modify-write पैटर्न है, उसी अंतराल के साथ।

Races कहाँ दिखते हैं

  • बैलेंस, क्रेडिट और wallets: वही पैसा दो बार ख़र्च करना।
  • Coupons और गिफ़्ट कार्ड: एक बार इस्तेमाल होने वाला कोड कई बार भुनाना।
  • Plan और usage limits: plan की अनुमति से ज़्यादा projects, seats या API कॉल बनाना।
  • साइन-अप और invitations: एक ही ईमेल से दो अकाउंट बनाना, या एक invite दो बार स्वीकार करना।
  • Voting, likes और ratings: एक यूज़र को एक से ज़्यादा बार गिनना।
  • Payment और order फ़्लो: जब कोई webhook और redirect साथ आ जाएँ तो एक order दो बार पूरा करना।

इनका फ़ायदा उठाना मुश्किल नहीं। Promise.all, curl या किसी proxy टूल से एक साथ requests का जत्था भेजना कुछ मिलीसेकंड की खिड़कियों पर वार करने के लिए काफ़ी है, और single-packet attack जैसी तकनीकें requests को सर्वर पर लगभग एक साथ पहुँचा देती हैं। मान लें कि अगर race मौजूद है, तो कोई उसे जीत सकता है।

फ़िक्स 1: नियम डेटाबेस से लागू करवाएँ

सबसे मज़बूत फ़िक्स नियम को डेटाबेस में ले जाते हैं, जहाँ समानांतर requests आपके लिए क्रम में लग जाती हैं। दो औज़ार ज़्यादातर मामले संभाल लेते हैं: unique constraints और atomic conditional updates।

-- प्रति यूज़र एक बार: दूसरा insert फ़ेल होता है, timing चाहे जो हो
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- कुल उपयोग सीमा: एक ही statement में जाँच और वृद्धि
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- बैलेंस: शर्त मौजूदा row पर जाँची जाती है
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- दोहरी सुरक्षा: बैलेंस कभी ऋणात्मक नहीं हो सकता
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

Conditional UPDATE इसलिए काम करता है क्योंकि WHERE clause का मूल्यांकन करते समय डेटाबेस row को lock कर लेता है। अगर दो requests race करती हैं, तो दूसरी उस row पर शर्त फिर से जाँचती है जिसे पहली ने commit किया। अगर कोई row वापस नहीं आती, तो शर्त फ़ेल हुई और आप error लौटाते हैं। फ़ायदा उठाने के लिए कोई अंतराल है ही नहीं क्योंकि जाँच और write एक ही statement हैं।

एप्लिकेशन कोड में unique violation को सामान्य नतीजा मानें। PostgreSQL में यह error code 23505 के रूप में आता है; उसे 500 के बजाय coupon पहले ही इस्तेमाल हो चुका जैसे साफ़ संदेश से मैप करें।

ज़्यादातर ORM conditional update व्यक्त कर सकते हैं। Prisma में, where clause में बैलेंस की शर्त के साथ updateMany एक count लौटाता है, और शून्य count का मतलब है जाँच फ़ेल हुई। findUnique के बाद अलग update वही गारंटी नहीं देता, चाहे जाँच कितनी भी सावधानी से लिखी गई हो।

फ़िक्स 2: SELECT ... FOR UPDATE से row lock करें

कभी-कभी फ़ैसले को एक से ज़्यादा statement चाहिए: कई fields पढ़ना, कोई pricing फ़ंक्शन कॉल करना, दो टेबलों में लिखना। तब किसी transaction के भीतर row lock लें। SELECT ... FOR UPDATE किसी भी दूसरी transaction को, जो उसी row को lock करने की कोशिश करे, आपकी transaction के commit या rollback होने तक रुकने पर मजबूर कर देता है।

अकेली transaction काफ़ी नहीं है। PostgreSQL का डिफ़ॉल्ट isolation level READ COMMITTED है, और उस स्तर पर पहले वाले vulnerable कोड को BEGIN और COMMIT में लपेटने से कुछ नहीं बदलता: दोनों transactions वही बैलेंस पढ़ती हैं, दोनों चेक पास करती हैं और दोनों writes सफल होते हैं। Lock ही वह चीज़ है जो दूसरी transaction को रुकने और फिर commit की गई value पढ़ने पर मजबूर करती है।

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

दो बातें मायने रखती हैं। Lock तभी मदद करता है जब बैलेंस बदलने वाला हर code path भी उसे ले; एक भी रास्ता जो बिना lock के update करे, race फिर से खोल देता है। और पूरे क्रम को एक ही client इस्तेमाल करना चाहिए: BEGIN किसी एक pooled connection पर और SELECT किसी दूसरे पर चलाने से आपको transaction मिलती ही नहीं। ORM यही पैटर्न interactive transactions या raw queries के ज़रिए देते हैं।

फ़िक्स 3: rows के आर-पार फैले नियमों के लिए advisory locks

Row locks तब मदद नहीं करते जब नियम उन rows के बारे में हो जो अभी मौजूद ही नहीं, जैसे फ़्री-plan यूज़र के पास ज़्यादा से ज़्यादा तीन projects हो सकते हैं। दो requests दो-दो projects गिन सकती हैं और दोनों एक तीसरा insert कर सकती हैं। PostgreSQL advisory locks आपको किसी मनमानी key, जैसे यूज़र ID, को transaction भर के लिए lock करने देते हैं।

Transaction के भीतर, यूज़र से निकाली गई key के साथ pg_advisory_xact_lock कॉल करें, फिर गिनें और insert करें। यह फ़ंक्शन 64-bit integer key लेता है, इसलिए यूज़र ID से एक स्थिर integer निकालें, उदाहरण के लिए hashtext से; असंबंधित यूज़र्स के बीच कभी-कभार होने वाली टक्कर बस थोड़े इंतज़ार की क़ीमत लेती है, कभी शुद्धता की नहीं। Lock commit या rollback पर अपने आप छूट जाता है। दूसरा विकल्प है SERIALIZABLE isolation, जो PostgreSQL से टकराने वाली transactions पकड़वाकर एक को error 40001 के साथ रद्द करवा देता है; यह अच्छा काम करता है, बशर्ते आपका कोड रद्द हुई transactions दोबारा चलाए।

जो काम नहीं करता वह है in-memory mutex या locks का कोई JavaScript Map। वह एक ही process को कवर करता है। जिस पल आप दो instances, दो serverless फ़ंक्शन या कोई background worker चलाते हैं, lock ख़त्म।

फ़िक्स 4: retries और double submits के लिए idempotency keys

कुछ duplicates हमले नहीं होते: यूज़र डबल-क्लिक करता है, कोई मोबाइल क्लाइंट timeout के बाद retry करता है, कोई पेमेंट प्रोवाइडर webhook दोबारा भेजता है। Idempotency key यह करो को यह एक बार करो में बदल देती है। क्लाइंट हर तार्किक operation के लिए एक key बनाता है, और सर्वर काम करने से पहले उसे unique constraint के साथ दर्ज कर लेता है।

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

-- पहले key पर दावा करें; शून्य rows का मतलब है वह किसी दूसरी request की है
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

अगर insert कोई row लौटाता है, तो उसी transaction में operation चलाएँ और response स्टोर करें। अगर कुछ नहीं लौटता, तो स्टोर किया गया response देखकर लौटाएँ, या अगर पहली request अब भी चल रही है तो 409 लौटाएँ। Keys को यूज़र तक scope करें ताकि एक यूज़र दूसरे की key replay या ब्लॉक न कर सके, और उन्हें एक वाजिब अवधि के बाद expire करें। Webhooks के लिए प्रोवाइडर की event ID स्वाभाविक key है। Key के साथ request body का hash स्टोर करने और अलग body वाली key का दोबारा इस्तेमाल अस्वीकार करने पर विचार करें, ताकि किसी क्लाइंट बग की वजह से चुपचाप किसी दूसरे operation का नतीजा न मिल जाए।

अपने कोड में races ढूँढना

  • ऐसा read खोजें जिसके बाद उसी डेटा का write हो और बीच में कोई await हो।
  • ऐसे counts खोजें जिनकी तुलना किसी insert से पहले limits से की जाती हो।
  • ऐसा ढूँढो-फिर-न-मिले-तो-बनाओ पैटर्न खोजें जिसके पीछे कोई unique constraint न हो।
  • जाँचें कि किसी संवेदनशील value पर हर write उसी locked या atomic रास्ते से जाता है।
  • ऐसा टेस्ट लिखें जो वही request समानांतर में चलाए और पुष्टि करे कि invariant अब भी क़ायम है।

समानांतर टेस्ट सबसे ठोस सबूत है। असली डेटाबेस के ख़िलाफ़ Promise.all से operation बीस बार चलाएँ, फिर बैलेंस, redemption count या rows की संख्या जाँचें। अगर यह फ़िक्स से पहले फ़ेल हो और बाद में पास, तो race बंद हो गई। इसे एक से ज़्यादा बार चलाएँ; पाँच में से एक बार फ़ेल होने वाली race भी race ही है।

Race conditions उस तरह के बग हैं जिन्हें कोई AI रिव्यूअर एक लाइन के बजाय पूरा फ़्लो पढ़कर सामने ला सकता है। जब CodeAuditAgent किसी को फ़्लैग करता है, तो फ़ाइंडिंग के साथ CWE, कोट की गई जाँच और write, समानांतर requests बताता एक exploit परिदृश्य, और एक पैच आता है, आमतौर पर ऊपर दिए फ़िक्स में से कोई एक। सामान्य नियम दोनों ही हाल में वही है: फ़ैसला डेटाबेस को करने दें, क्योंकि वही इकलौता हिस्सा है जो हर request देखता है।