IlmHamroh
JavaScript Full-stack/28-qism. Autentifikatsiya va backend xavfsizligi30/31-dars22 daqiqa
Mundarija (32)

CAPTCHA tokenini serverda tekshirish: Turnstile siteverify, asal idish va SMS pumping

Qisqacha: CAPTCHA vidjeti brauzerda token beradi, lekin token o'zi hech narsani isbotlamaydi. Server uni maxfiy kalit bilan siteverify ga yuborib tekshirishi shart. vazifalar-api da Turnstile tokeni X-Captcha sarlavhasida keladi va faqat bot uchun "foydali" yo'llarda talab qilinadi: ro'yxat, parol tiklash so'rovi va SMS kod. Tarmoq xatosida server rad etadi (yopiq yiqilish). Kanon Cloudflare'ning rasmiy test kalitlari bilan haqiqiy siteverify ni chaqirib, kutilmagan narsani topdi: test kaliti hostname: "example.com" qaytaradi — domen tekshiruvi bilan birga ishlamaydi. Yana ikki qatlam: yashirin asal idish (honeypot) maydoni va SMS'ga kunlik global byudjet — pulga tushiradigan "SMS pumping" hujumiga qarshi.

Bu darsda

  • CAPTCHA tokeni nega serverda tekshirilishini va siteverify javobini qanday o'qishni tushuntirasiz.
  • Rasmiy test kalitlari bilan haqiqiy javoblarni ko'rib, hostname tuzog'ini bilasiz.
  • Yopiq yiqilish (fail closed) va CAPTCHA'ni qaysi yo'llarga qo'yish qarorini asoslaysiz.
  • Asal idish maydonini "bir xil javob" tamoyili bilan yozasiz.
  • SMS pumping hujumini va unga qarshi to'rt qatlamni tushuntirasiz; vazifalar-api ga bot himoyasini va 4 ta testni qo'shasiz.

Oldin bilishingiz kerak: CAPTCHA vidjetini frontendga qo'shish: Turnstile va reCAPTCHA, OTP orqali login, Rate limiting, Server Actions xavfsizligi (asal idish).

Oldingi darsda bitta hujumchi ko'p so'rov bilan serverni band qilmoqchi edi. Bugungi hujumchi boshqacha: u serverni yiqitmaydi, uni ishlatadi — minglab soxta hisob ochadi, begona emailga xat yuboradi, sizning hisobingizdan SMS jo'natadi. Har so'rov "to'g'ri", faqat uni odam emas, dastur yuborgan.

1. Nega bu kerak?

21-qismda CAPTCHA vidjeti ni formaga qo'shgansiz: vidjet "odammi?" deb baholaydi va formaga token beradi. O'sha darsda bir qoida bor edi: tekshirilmagan token — himoya emas. Bugun o'sha tekshiruvni backend'da yozamiz.

Nega frontend yetmaydi? Chunki bot sizning formangizni umuman ochmaydi. U POST /v1/auth/royxat ga to'g'ridan-to'g'ri so'rov yuboradi — curl bilan. Vidjet ham, JavaScript ham, tugma ham yo'q. Server tokenni talab qilmasa — CAPTCHA faqat odamlarga xalaqit beradi, botlarga emas.

vazifalar-api da botlar uchun "qimmatli" uchta yo'l bor:

Yo'l Bot nima qiladi Kim zarar ko'radi
POST /royxat minglab soxta hisob, spam baza, boshqa foydalanuvchilar
POST /parol-tiklash/sorov begona emailga xat oqimi email obro'yingiz, qurbon
POST /otp/yubor SMS yuborish sizning hamyoningiz

Uchinchisi — eng xavflisi. Uni alohida bo'limda ko'ramiz.

2. siteverify: tokenni serverda tekshirish

2.1 Oqim

sequenceDiagram
  participant B as Brauzer
  participant C as Cloudflare
  participant A as vazifalar-api
  B->>C: vidjet (sayt kaliti)
  C-->>B: token
  B->>A: POST /royxat, X-Captcha: token
  A->>C: siteverify: maxfiy kalit + token
  C-->>A: success, hostname, error-codes
  A-->>B: 202 yoki 400 captcha

Server Cloudflare'ning https://challenges.cloudflare.com/turnstile/v0/siteverify manziliga POST yuboradi. Tana — forma formatida uchta maydon:

js
const tana = new URLSearchParams({
  secret: "sir",
  response: "tok",
  remoteip: "203.0.113.5",
});
console.log(tana.toString());

const ip = undefined;
const ipsiz = new URLSearchParams({
  secret: "sir",
  response: "tok",
  ...(ip === undefined ? {} : { remoteip: ip }),
});
console.log(ipsiz.toString());

Konsolda:

text
secret=sir&response=tok&remoteip=203.0.113.5
secret=sir&response=tok
  • secret — maxfiy kalit (secret key): faqat serverda, .env da. Brauzerga hech qachon chiqmaydi.
  • response — brauzerdan kelgan token.
  • remoteip — foydalanuvchi IP'si, ixtiyoriy. Ikkinchi qatordagi naqsh kanondan: IP noma'lum bo'lsa, maydon umuman yuborilmaydi (undefined satri emas).

Kanon testi aynan shu tanani kutadi: secret=sir&response=tok&remoteip=203.0.113.5.

2.2 Haqiqiy javoblar — rasmiy test kalitlari bilan

Cloudflare hujjatida (2026-10 holatida) sinov uchun ochiq test maxfiy kalitlari bor. Ular har qanday tokenga oldindan belgilangan javob qaytaradi. Kanon Cloudflare'da hisob ochmadi va haqiqiy kalit ishlatmadi — faqat shu test kalitlari bilan haqiqiy siteverify ga so'rov yubordi. Token — test vidjeti beradigan soxta satr, IP yuborilmadi:

js
const URL =
  "https://challenges.cloudflare.com/turnstile/v0/siteverify";
for (const sir of ["1x0000000000000000000000000000000AA",
  "2x0000000000000000000000000000000AA",
  "3x0000000000000000000000000000000AA"]) {
  const r = await fetch(URL, {
    method: "POST",
    body: new URLSearchParams({
      secret: sir, response: "XXXX.DUMMY.TOKEN.XXXX",
    }),
    signal: AbortSignal.timeout(10_000),
  });
  const j = await r.json();
  console.log(sir.slice(0, 2), r.status, JSON.stringify(j));
}

Bu — kanon skriptining qisqartirilgan nusxasi. Quyidagi natija to'liq skriptdan (u har kalitga nom va javob vaqtini ham yozadi, bo'sh token bilan to'rtinchi so'rov ham yuboradi; 2026-10-28, Node 24.21.0; uzun JSON qatorlarini qisqartirmadik). Har qatorda HTTP status, success va error-codes ga qarang:

text
  doim o'tadi (1x…AA)          HTTP 200, 419 ms → {"challenge_ts":"2026-10-28T11:25:11.164Z","error-codes":[],"hostname":"example.com","metadata":{"result_with_testing_key":true},"success":true}
  doim rad (2x…AA)             HTTP 200, 105 ms → {"error-codes":["invalid-input-response"],"success":false,"messages":[],"metadata":{"result_with_testing_key":true}}
  token ishlatilgan (3x…AA)    HTTP 200, 105 ms → {"error-codes":["timeout-or-duplicate"],"success":false,"messages":[],"metadata":{"result_with_testing_key":true}}
  bo'sh token bilan (1x)          HTTP 200 → {"error-codes":["missing-input-response"],"success":false,"messages":[]}

Bu blokni saytda ishga tushirib bo'lmaydi (tarmoq va Cloudflare kerak), natija o'quv tajribasidan. Nimalarni o'qiymiz:

  • HTTP status hamisha 200. Hatto rad etilganda ham. Shuning uchun r.ok ga qarab qaror qilish — xato. Qaror faqat tanadagi success maydoniga qarab.
  • error-codes — rad sababi: invalid-input-response (token yaroqsiz), timeout-or-duplicate (token eskirgan yoki ikkinchi marta ishlatilgan), missing-input-response (token yo'q).
  • metadata.result_with_testing_key: true — "bu test kaliti javobi". Prodakshn loglarida buni ko'rsangiz — sozlama xato.
  • Tokenning bir martalik ekani muhim: bir tokenni ikki so'rovda ishlatib bo'lmaydi, va u 300 soniya (5 daqiqa) yashaydi.

2.3 Topilma: test kaliti va hostname

Birinchi qatorga yana qarang: "hostname":"example.com". Haqiqiy javobda hostname — token qaysi saytda olinganini bildiradi. Server uni o'z domeni bilan solishtirsa, boshqa saytda (masalan, hujumchining "token fermasida") olingan tokenni rad etadi. Kanon bu tekshiruvni qo'ydi (TURNSTILE_HOSTNAME).

Lekin test kaliti har doim example.com qaytaradi. Natija: lokal sinovda test kaliti va TURNSTILE_HOSTNAME=vazifalar.uz ni birga bersangiz — hamma rad etiladi, "doim o'tadigan" kalit bilan ham. Kanonning qaror funksiyasini o'sha haqiqiy javoblarga beramiz:

js
const JAVOBLAR = {
  "1x (doim o'tadi)": { success: true, hostname: "example.com",
    "error-codes": [], metadata: { result_with_testing_key: true } },
  "2x (doim rad)": { success: false,
    "error-codes": ["invalid-input-response"] },
  "3x (ishlatilgan)": { success: false,
    "error-codes": ["timeout-or-duplicate"] },
  "bo'sh token": { success: false,
    "error-codes": ["missing-input-response"] },
};

function qaror(j, hostname) {
  if (j.success !== true) {
    const kodlar = j["error-codes"];
    return Array.isArray(kodlar) ? kodlar.join(",") : "rad";
  }
  if (hostname !== undefined && j.hostname !== hostname) {
    return "hostname";
  }
  return "ok";
}

for (const [nom, j] of Object.entries(JAVOBLAR)) {
  const uz = qaror(j, "vazifalar.uz");
  console.log(nom.padEnd(18), qaror(j), "|", uz);
}

Konsolda:

text
1x (doim o'tadi)   ok | hostname
2x (doim rad)      invalid-input-response | invalid-input-response
3x (ishlatilgan)   timeout-or-duplicate | timeout-or-duplicate
bo'sh token        missing-input-response | missing-input-response

Birinchi qatorning o'ng tomoni — tuzoq. Kanon buni .env.example izohiga yozdi: "test kaliti bilan TURNSTILE_HOSTNAME bermang". Saboq kengroq: test muhiti prodakshndan farq qiladi — test kaliti bilan "hammasi ishladi" degani prodakshn sozlamasi to'g'ri degani emas.

Tekshirib ko'ring: Nega j.success === true deb yozilgan, if (j.success) emas?

Javob

Javob — tashqi ma'lumot, ishonib bo'lmaydi. if (j.success) "false" satrini, 1 ni yoki bo'sh bo'lmagan har qanday obyektni ham "rost" deb qabul qilardi. === true faqat haqiqiy mantiqiy true ni o'tkazadi. Kanonda javob tipi ham success?: unknown — TypeScript har maydonni tekshirishga majburlaydi.

3. Kanon dizayni: qayerda, qanday va xato bo'lsa nima

3.1 Turnstile klassi

Kanon src/auth/captcha.ts — 70 qatorga yaqin. Asosiy qarorlar:

Qaror Kodda Nega
token yo'q yoki > 2 048 belgi so'rov yubormaydi, rad Cloudflare'ga keraksiz so'rov yo'q; chegara — Cloudflare hujjatida
AbortSignal.timeout(3000) 3 s Cloudflare sekin bo'lsa, server osilmasin
tarmoq xatosi rad (siteverify-xato) yopiq yiqilish
success !== true rad, sabab — error-codes jurnalda ko'rinadi
hostname berilgan va mos emas rad begona saytdagi token

fetchFn — tikuv: testda soxta fetch beriladi, haqiqiy Cloudflare'ga so'rov ketmaydi. Interfeys (CaptchaTekshiruvchi) ham ataylab: ertaga hCaptcha yoki boshqa provayderga o'tsangiz — faqat yangi klass yoziladi, marshrutlar o'zgarmaydi.

3.2 Yopiq yiqilish

Cloudflare javob bermasa nima qilamiz? Ikki yo'l:

  • yopiq yiqilish (fail closed) — rad etamiz: odamlar ham vaqtincha ro'yxatdan o'ta olmaydi;
  • ochiq yiqilish (fail open) — o'tkazamiz: botlar ham o'tadi.

Kanon CAPTCHA uchun yopiqni tanladi. Qiziq tomoni: Rate limiting darsida tezlik cheklagichi uchun aksincha qaror qabul qilingan — u buzilsa so'rov o'tadi (fail open), jurnalga yoziladi. Nega bir loyihada ikki xil qaror?

  • Cheklagich login yo'lida ham turadi. U buzilsa va hamma rad etilsa — hech kim tizimga kira olmaydi, butun ilova to'xtaydi.
  • CAPTCHA esa faqat yangi hisob, xat va SMS yo'llarida. Ular bir necha daqiqa ishlamasa — noqulay, lekin ilova ishlaydi. O'tkazib yuborsak esa — bot ayni shu daqiqalarni kutib turgan bo'lishi mumkin, SMS esa pul.

Qaror har yo'l uchun alohida qilinadi: "rad etish qanchaga tushadi" va "o'tkazish qanchaga tushadi".

3.3 Qaysi yo'llarda

Token X-Captcha sarlavhasida keladi va faqat uchta POST yo'lda talab qilinadi: /royxat, /parol-tiklash/sorov, /otp/yubor. CAPTCHA faqat TURNSTILE_SIRI berilganda yoqiladi — lokal ishlab chiqishda va testlarda u o'chiq.

Login — CAPTCHA'siz. Bu ataylab. Login — eng ko'p ishlatiladigan yo'l, har kirishda CAPTCHA odamlarni charchatadi. Login'ni hisob + IP bo'yicha tezlik cheklovi va oldingi darsdagi semafor himoyalaydi. Ko'p saytlar oraliq yo'lni tanlaydi: bir necha xato urinishdan keyingina CAPTCHA so'raydi. Kanon buni qilmadi — bu 2-mashq.

Javob — 400 va Problem Details kod: "captcha", jurnalga bot_tutildi hodisasi (sabab bilan). Hisob yaratilmaydi, xat ketmaydi.

Maslahat: Nega token tanada emas, sarlavhada? Uchala yo'lning tana sxemasi strictObject — ortiqcha maydon 422 beradi (Backend'dagi injection va mass assignment). Tokenni sarlavhaga qo'yish uchala sxemaga tegmaslikka imkon berdi va tekshiruvni bitta middleware'ga yig'di.

4. Asal idish (honeypot)

4.1 G'oya va "bir xil javob"

Asal idish (honeypot) — formadagi odam ko'rmaydigan maydon (Server Actions xavfsizligi darsida ko'rgansiz). Oddiy spam-bot HTML'dagi hamma maydonni to'ldiradi, odam esa ko'rinmas maydonga hech narsa yozmaydi. vazifalar-api da bu maydon — vebSayt: ro'yxat formasida CSS bilan yashirilgan.

Muhim savol: bot tutilsa, nima javob beramiz? Kanon qarori — aynan o'sha 202 va o'sha matn, odamga beriladigandek. Hisob yaratilmaydi, xat ketmaydi, faqat jurnalga bot_tutildi (usul: honeypot) yoziladi.

Nega 400 emas? Bot muallifi 400 ni ko'rsa, maydonni topadi va keyingi versiyada uni bo'sh qoldiradi. 202 ni ko'rsa — "ishladi" deb o'ylaydi va davom etadi, natijada esa hech narsa yo'q. Bu Ro'yxat va login oqimi dagi "bir xil javob" tamoyilining davomi: tashqaridan qarab, ichkarida nima bo'lganini bilib bo'lmasin.

4.2 Sxemadagi maydon

RoyxatSxemasi — strictObject. Agar vebSayt sxemada bo'lmasa, uni to'ldirgan bot 422 olardi va asal idish o'zini oshkor qilardi. Shuning uchun maydon sxemaga qo'shildi — ixtiyoriy va uzunligi cheklangan:

ts
vebSayt: z.string().max(200).optional(),

Asal idishning chegarasi ham bor: u faqat "hamma maydonni to'ldiradigan" sodda botlarni ushlaydi. Saytingiz uchun maxsus yozilgan bot uni chetlab o'tadi. Shuning uchun u CAPTCHA va tezlik cheklovining o'rnida emas, yonida.

5. SMS pumping

5.1 Hujum

OTP orqali login darsida SMS kod yuborishni qo'shgansiz. Har SMS uchun SMS provayderi sizdan pul oladi. Xalqaro raqamlarga — ancha qimmat.

SMS pumping (yoki SMS toll fraud) shunday ishlaydi: hujumchi qimmat tarifli xalqaro raqamlar bilan "hamkorlik" qiladi — bu raqamlarga kelgan har SMS uchun aloqa operatori unga ulush to'laydi. Keyin u skript bilan sizning «Kod yuborish» formangizga shu raqamlarni minglab yuboradi. Sizning hisobingizdan pul ketadi, hujumchi daromad oladi. Hech qanday hisob buzilmaydi, hech qanday ma'lumot o'g'irlanmaydi — faqat to'lov hisobi bir kechada o'sib ketadi.

5.2 To'rt qatlam

Qatlam Qayerda qo'shilgan Nimaga qarshi
SMS faqat hisobga ulangan / ulanayotgan raqamga OTP darsi begona raqamga yuborish
mamlakat ro'yxati (OTP_MAMLAKATLAR=+998) OTP darsi qimmat xalqaro raqamlar
raqam va IP chastotasi OTP, tezlik cheklovi bitta manbadan ko'p
kunlik global byudjet bu dars ko'p IP, ko'p hisob

Birinchi uchtasi allaqachon bor edi. Ular "bitta hujumchi"ga qarshi. Lekin minglab IP va minglab hisob bilan har biri o'z chegarasi ichida qoladi. Oxirgi chiziq — butun ilova bo'yicha: bir kunda ko'pi bilan SMS_KUNLIK_MAKS ta SMS (sukut 1 000). Oshsa — SMS yuborilmaydi va jurnalga kunlik-byudjet yoziladi.

Byudjet Rate limiting dagi cheklagichdan foydalanadi, faqat kalit — hammaga bitta: sms-kunlik:hammasi, oyna — 24 soat. Yangi jadval ham, yangi kod ham kerak bo'lmadi.

5.3 Byudjet tugasa — signal

Kunlik byudjetga yetish — oddiy holat emas. Bu yo hujum, yo kutilmagan o'sish. Ikkalasida ham odam bilishi kerak. Shuning uchun kanon bu hodisani jurnalga alohida belgi bilan yozadi — monitoring shu belgiga ogohlantirish qo'yadi (OWASP Top 10:2025 xaritasi dagi A09 — "Alerting").

Bir trade-off'ni ochiq aytamiz: byudjet tugagach, haqiqiy foydalanuvchilar ham SMS ololmaydi — kun oxirigacha. Bu ham DoS'ning bir turi. Kanon pulni himoya qilishni tanladi. Katta tizimlarda byudjet mamlakat va prefiks bo'yicha bo'linadi yoki SMS provayderining o'z "fraud guard" xizmati ishlatiladi — kanonda sinalmagan.

Tekshirib ko'ring: Byudjet tekshiruvi nega javobdan keyin, fon ishida turadi? Javobda "byudjet tugadi" deyish yaxshi emasmi?

Javob

OTP so'rovi hamisha bir xil javob qaytaradi — raqam bormi, kod ketdimi, oshkor qilinmaydi (OTP orqali login dagi qoida). "Byudjet tugadi" deyish hujumchiga signal bo'lardi: "hujum ishladi, ertaga davom et" yoki "chegarangiz shu yerda". Javob o'zgarmaydi — farqni faqat jurnal ko'radi.

6. Boshqa CAPTCHA'lar

Kanon faqat Turnstile'ni sinadi. Qolgan ikki keng tarqalgani — hujjat bo'yicha (sinalmagan):

  • hCaptcha — https://api.hcaptcha.com/siteverify, javob shakli o'xshash (success, hostname, error-codes).
  • reCAPTCHA v3 — vidjetsiz, har so'rovga ball beradi (0.0 — bot, 1.0 — odam). Server success dan tashqari score ni o'z chegarasi bilan va action nomini solishtiradi. Ball — ehtimollik: chegarani qayerga qo'yishni sizning trafikingiz hal qiladi.

Interfeys tufayli vazifalar-api da provayder almashtirish — bitta yangi klass.

CAPTCHA'ning narxi ham bor: ko'rish yoki eshitish qiyinligi bo'lgan foydalanuvchilarga (masalan, ekran o'quvchidan foydalanadigan Malika) murakkab topshiriqlar to'siq bo'ladi. Turnstile ko'pincha ko'rinmas tekshiruv bilan ishlaydi — lekin baribir yuklanmasa, odam ham o'ta olmaydi. Shuning uchun CAPTCHA faqat kerakli yo'llarda.

7. Hujumchi nigohi

Hujumchi sinaydi vazifalar-api
curl bilan /royxat, X-Captcha siz 400 captcha, hisob yo'q
eski yoki boshqa saytdagi token timeout-or-duplicate / hostname — 400
Cloudflare'ga yo'lni bloklash (tarmoq xatosi) rad — yopiq yiqilish
formaning hamma maydonini to'ldirish 202, lekin hisob ham, xat ham yo'q
10 000 IP'dan SMS so'rovi kunlik byudjet, jurnal signali
login'ga CAPTCHA'siz toshqin tezlik cheklovi va semafor

8. Ko'p uchraydigan xatolar

Xato Nega noto'g'ri To'g'risi
tokenni faqat frontendda tekshirish bot formani ochmaydi serverda siteverify
r.ok ga qarab qaror rad ham HTTP 200 success === true
tarmoq xatosida o'tkazish bot shu paytni kutadi yopiq yiqilish (xavfli yo'llarda)
maxfiy kalit frontend kodida hamma ko'radi faqat server .env
test kaliti + hostname tekshiruvi hamma rad test kalitida hostname bermang
asal idishda 400 qaytarish bot maydonni topadi bir xil 202
asal idish maydoni sxemada yo'q (strictObject) 422 — oshkor sxemaga ixtiyoriy maydon
SMS'ni faqat IP bo'yicha cheklash ko'p IP + global kunlik byudjet

9. Mashqlar

Mashqlar kurs/mashqlar/28/30-captcha/ da.

1-mashq (oson): qaysi javob?

vazifalar-api (CAPTCHA yoqilgan) quyidagilarga nima javob beradi?

  1. POST /royxat, X-Captcha yo'q.
  2. POST /kirish, X-Captcha yo'q, parol to'g'ri, email tasdiqlangan.
  3. POST /royxat, token to'g'ri, vebSayt: "https://spam.uz".
  4. POST /otp/yubor, token to'g'ri, lekin bugun 1 000 SMS allaqachon ketgan.
Yechim
  1. 400 captcha, hisob yaratilmaydi, jurnalda bot_tutildi (usul: captcha).
  2. 200 — login CAPTCHA'siz; uni tezlik cheklovi himoyalaydi.
  3. 202 — odamga beriladigan javobning aynan o'zi. Lekin hisob ham, xat ham yo'q; jurnalda bot_tutildi (usul: honeypot).
  4. Odatdagi bir xil javob — lekin SMS ketmaydi, jurnalda kunlik-byudjet.

2-mashq (o'rta): xatolardan keyin CAPTCHA

Login uchun "aqlli" qoida yozing: captchaKerakmi(xatolar) — oxirgi 15 daqiqadagi xato urinishlar soni 3 va undan ko'p bo'lsa true. Keyin kirish(holat, captchaOk) funksiyasi qaror qaytarsin: CAPTCHA kerak, lekin captchaOk emas — "400 captcha"; aks holda — "tekshirish". Besh holatni sinang: 0, 2, 3 xato (CAPTCHA'siz) va 3, 5 xato (CAPTCHA bilan).

Yechim
js
const CHEGARA = 3;
const captchaKerakmi = (xatolar) => xatolar >= CHEGARA;

function kirish(xatolar, captchaOk) {
  if (captchaKerakmi(xatolar) && !captchaOk) return "400 captcha";
  return "tekshirish";
}

for (const [xatolar, ok] of [[0, false], [2, false], [3, false],
  [3, true], [5, true]]) {
  console.log(`xato ${xatolar}, captcha ${ok}:`, kirish(xatolar, ok));
}

Konsolda:

text
xato 0, captcha false: tekshirish
xato 2, captcha false: tekshirish
xato 3, captcha false: 400 captcha
xato 3, captcha true: tekshirish
xato 5, captcha true: tekshirish

Halol odam deyarli hech qachon CAPTCHA ko'rmaydi, parol terayotgan bot esa uchinchi urinishdan keyin to'siladi. Xato hisobi qaysi kalit bo'yicha — hisob, IP yoki ikkalasi — tezlik cheklovidagi kabi qaror. Faqat hisob bo'yicha bo'lsa, hujumchi qurbonga CAPTCHA "yuklab" qo'yishi mumkin; bu, ehtimol, qabul qilinadigan narx.

3-mashq (qiyin): kunlik byudjet va prefiks

SmsByudjet klassini yozing: umumiy kunlik chegara va har mamlakat prefiksi uchun alohida chegara. ruxsat(telefon) — ikkala chegara ichida bo'lsa hisoblagichlarni oshirib true, aks holda false qaytarsin (hisoblagich oshirilmaydi). Prefiks — ro'yxatdagi eng uzun mos keluvchisi (+998, +7); ro'yxatda yo'q raqam — false. Sinov: umumiy 4, +998 uchun 3, +7 uchun 1.

Yechim
js
class SmsByudjet {
  #jami = 0;
  #prefikslar = new Map();
  constructor(umumiy, chegaralar) {
    this.umumiy = umumiy;
    this.chegaralar = chegaralar;
  }
  #prefiks(tel) {
    return Object.keys(this.chegaralar)
      .filter((p) => tel.startsWith(p))
      .toSorted((a, b) => b.length - a.length)[0];
  }
  ruxsat(tel) {
    const p = this.#prefiks(tel);
    if (p === undefined) return false;
    const n = this.#prefikslar.get(p) ?? 0;
    if (this.#jami >= this.umumiy || n >= this.chegaralar[p]) {
      return false;
    }
    this.#jami++;
    this.#prefikslar.set(p, n + 1);
    return true;
  }
}

const b = new SmsByudjet(4, { "+998": 3, "+7": 1 });
for (const t of ["+998901", "+79001", "+79002", "+998902",
  "+998903", "+998904", "+44700"]) {
  console.log(t, b.ruxsat(t));
}

Konsolda:

text
+998901 true
+79001 true
+79002 false
+998902 true
+998903 true
+998904 false
+44700 false

+79002 — +7 chegarasi (1) tugadi. +998904 — umumiy chegara (4) tugadi, garchi +998 uchun ham joy qolmagan bo'lsa ham. +44700 — prefiks ro'yxatda yo'q. Real kodda hisoblagich baza yoki Redis'da va 24 soatlik oynada turadi — bu yerda faqat qaror mantig'i.

10. Loyiha qadami: captcha.ts, asal idish va SMS byudjeti

10.1 src/auth/captcha.ts

Yangi modul — to'liq. Telefon uchun bo'lingan, boshidagi izoh qisqartirilgan; mantiq kanondagidek (bo'lingan nusxani kanon loyihasi nusxasida tsc, ESLint va testlar bilan tekshirdik):

ts
// captcha.ts — CAPTCHA tokenini SERVERDA tekshirish: Cloudflare
// Turnstile siteverify (developers.cloudflare.com/turnstile). Brauzer
// vidjeti token beradi; token o'zi hech narsani isbotlamaydi — server
// uni sir kalit bilan Cloudflare'ga yuborib tekshiradi (bir marta,
// 300 s). Kanon sinovi: fetch — tikuv (mock); haqiqiy siteverify —
// faqat RASMIY TEST kalitlari bilan, haqiqiy hisob ishlatilmagan
export const SITEVERIFY =
  "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export interface CaptchaTekshiruvchi {
  /** true — inson (yoki test kaliti); tarmoq xatosi va javobsizlik
   * — false (yopiq holatda xavfsiz) */
  tekshir(
    token: string | undefined,
    ip: string | undefined,
  ): Promise<{ ok: boolean; sabab?: string }>;
}

type Javob = {
  success?: unknown;
  "error-codes"?: unknown;
  hostname?: unknown;
  action?: unknown;
};

export class Turnstile implements CaptchaTekshiruvchi {
  readonly #sir: string;
  readonly #fetch: typeof fetch;
  readonly #hostname: string | undefined;

  /** @param hostname berilsa — token boshqa saytda olingan bo'lsa
   * rad (token "ko'chirib" kelinmasin) */
  constructor(
    sir: string,
    { fetchFn = globalThis.fetch, hostname }:
      { fetchFn?: typeof fetch; hostname?: string } = {},
  ) {
    this.#sir = sir;
    this.#fetch = fetchFn;
    this.#hostname = hostname;
  }

  async tekshir(
    token: string | undefined,
    ip: string | undefined,
  ): Promise<{ ok: boolean; sabab?: string }> {
    if (token === undefined || token === "" || token.length > 2048) {
      return { ok: false, sabab: "token-yoq" };
    }
    const tana = new URLSearchParams({
      secret: this.#sir,
      response: token,
      ...(ip === undefined ? {} : { remoteip: ip }),
    });
    let j: Javob;
    try {
      const r = await this.#fetch(SITEVERIFY, {
        method: "POST",
        body: tana,
        signal: AbortSignal.timeout(3000),
      });
      j = (await r.json()) as Javob;
    } catch {
      return { ok: false, sabab: "siteverify-xato" };
    }
    if (j.success !== true) {
      const kodlar = j["error-codes"];
      const sabab = Array.isArray(kodlar) ? kodlar.join(",") : "rad";
      return { ok: false, sabab };
    }
    const kutilgan = this.#hostname;
    if (kutilgan !== undefined && j.hostname !== kutilgan) {
      return { ok: false, sabab: "hostname" };
    }
    return { ok: true };
  }
}

try ichida ikki narsa bor: fetch ham, r.json() ham. Cloudflare JSON o'rniga HTML xato sahifasi qaytarsa, json() xato tashlaydi — u ham siteverify-xato va rad.

10.2 Marshrut, sxema va byudjet

src/auth/marshrut.ts — CAPTCHA middleware va asal idish (bo'lingan):

ts
// CAPTCHA — bot hujumi nishoni bo'lgan yo'llar (hisob ochish,
// xat/SMS yuborish). Token sarlavhada: X-Captcha (vidjet bergan)
const captchaKerak = async (req: Request): Promise<void> => {
  if (d.captcha === undefined) return;
  const n = await d.captcha.tekshir(req.get("x-captcha"), req.ip);
  if (!n.ok) {
    await xizmat.hodisa({
      tur: "bot_tutildi", ...mijoz(req),
      tafsilot: { usul: "captcha", sabab: n.sabab },
    });
    throw new IlovaXatosi(400,
      "CAPTCHA tasdiqlanmadi — sahifani yangilab " +
        "qayta urinib ko'ring",
      { kod: "captcha", sarlavha: "CAPTCHA tasdiqlanmadi" });
  }
};
for (const y of ["/royxat", "/parol-tiklash/sorov", "/otp/yubor"]) {
  r.use(y, (req, _res, next) => {
    if (req.method !== "POST") { next(); return; }
    captchaKerak(req).then(() => { next(); }, next);
  });
}

captchaKerak(req).then(() => { next(); }, next) — Express 5 middleware'ida asinxron xato next(xato) orqali markaziy xato ishlovchiga boradi. U 400 ni Problem Details'ga aylantiradi.

/royxat ishlovchisining boshida — asal idish:

ts
// honeypot: bot yashirin maydonni to'ldirdi — AYNAN o'sha 202
// (bot "muvaffaqiyat" ko'rsin); hisob ham, xat ham yo'q — jurnal
if (body.vebSayt !== undefined && body.vebSayt !== "") {
  await xizmat.hodisa({
    tur: "bot_tutildi", ...mijoz(req),
    tafsilot: { usul: "honeypot" },
  });
  res.status(202).json({ xabar: XAT_YUBORILDI });
  return;
}

src/auth/sxema.ts da RoyxatSxemasi ga vebSayt: z.string().max(200).optional() qo'shildi. src/auth/ombor.ts dagi hodisa turlariga — "bot_tutildi".

src/auth/xizmat.ts — SMS fon ishining boshida, kod yaratishdan oldin:

ts
// kunlik global byudjet — raqam/IP cheklovlaridan tashqari
// (ko'p IP, ko'p hisob bilan "nasos"); oshsa — SMS yo'q,
// jurnalga (ogohlantirish signali)
const b = await d.cheklagich.ol("sms-kunlik:hammasi",
  d.smsKunlikMaks ?? 1000, 86_400_000);
if (!b.ruxsat) {
  await ombor.hodisa({
    tur: "otp_sorov", foydalanuvchiId: egasi, ...mijoz,
    tafsilot: { maqsad, cheklov: "kunlik-byudjet" },
  });
  return;
}

86_400_000 — 24 soat millisekundda. Byudjet faqat SMS haqiqatan yuboriladigan holatda sarflanadi (raqam topilgandan keyin) — begona raqamga so'rovlar byudjetni "yemaydi".

Sozlamalar (src/config.ts, Zod bilan): TURNSTILE_SIRI (ixtiyoriy, kamida 10 belgi; berilmasa CAPTCHA o'chiq), TURNSTILE_HOSTNAME (ixtiyoriy), SMS_KUNLIK_MAKS (sukut 1 000). src/server.ts ularni ulaydi:

ts
captcha: config.TURNSTILE_SIRI === undefined ? undefined
  : new Turnstile(config.TURNSTILE_SIRI,
      config.TURNSTILE_HOSTNAME === undefined
        ? {} : { hostname: config.TURNSTILE_HOSTNAME }),
smsKunlikMaks: config.SMS_KUNLIK_MAKS,

10.3 Testlar

test/bot.test.ts — 4 test. Birinchisi Cloudflare'siz (soxta fetch): so'rov tanasi, error-codes, hostname, tarmoq xatosi, tokensiz holat (fetch umuman chaqirilmaydi). Qolgan uchtasi haqiqiy Postgres bilan: CAPTCHA'li API, asal idish va byudjet. Asal idish testining asosi (bo'lingan):

ts
const a = await request(app).post("/v1/auth/royxat").send({
  email: "bot@example.com", toliqIsm: "Bot", parol: PAROL,
  vebSayt: "https://spam.example",
}).expect(202);
const b = await request(app).post("/v1/auth/royxat").send({
  email: "odam@example.com", toliqIsm: "Odam", parol: PAROL,
  vebSayt: "",
}).expect(202);
assert.deepEqual(a.body, b.body);
await d.fon.tugashi();
assert.deepEqual((await pool.query<{ email: string }>(
  "SELECT email FROM foydalanuvchilar")).rows,
  [{ email: "odam@example.com" }]);
assert.deepEqual(d.pochtachi.yuborilgan.map((x) => x.kimga),
  ["odam@example.com"]);

assert.deepEqual(a.body, b.body) — testning yuragi: bot va odam bir xil javob oldi. Farq faqat bazada va pochtada.

Ishga tushiramiz (biz ishga tushirdik: kanon nusxasi, bo'lingan captcha.ts va marshrut qismlari, Node 24.21.0, test bazasi — lokal PostgreSQL 17.6 klasterida kanon sxemasi). Har test nomi bitta himoya qatlamini aytadi:

bash
node --test test/bot.test.ts
text
▶ Turnstile (mock fetch)
  ✔ success — ok; error-codes — rad (sabab bilan); hostname mos emas — rad; tarmoq xatosi — rad (yopiq holat); tokensiz — fetch chaqirilmaydi (7.0021ms)
✔ Turnstile (mock fetch) (8.2728ms)
✔ CAPTCHA yoqilgan: X-Captcha'siz yoki noto'g'ri — 400 captcha (hisob yaratilmaydi); to'g'ri — 202; login — CAPTCHA'siz (1047.8054ms)
✔ honeypot: yashirin vebSayt maydoni to'ldirilgan — o'sha 202, lekin hisob ham, xat ham yo'q (407.2057ms)
✔ SMS pumping: kunlik global byudjet (testda 1) — ikkinchi foydalanuvchiga SMS ketmaydi, jurnalda kunlik-byudjet (457.25ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 29376.4718

Nima bo'ldi? To'rtta test — to'rtta qatlam: token tekshiruvi, CAPTCHA'li yo'llar, asal idish va kunlik byudjet. Postgres yo'q bo'lsa, oxirgi uchtasi "skipped" bo'ladi — Turnstile testi baribir ishlaydi. Testlarda Cloudflare'ga birorta so'rov ketmaydi.

10.4 Tekshiruv va commit

Butun loyiha tekshiruvi (kanon, 2026-10-28, PostgreSQL 18.6): tip va lint xatosiz, testlar 233 (232 + 1 o'tkazilgan) — to'rtta yangi test; brauzer testlari 10/10, migratsiya yo'q.

.env.example ni bu darsda o'zgartirmang — keyingi darsda butun 28-qism sozlamalari bitta bo'limga yig'iladi (Turnstile test kaliti tuzog'i haqidagi izoh bilan).

bash
M="feat(auth): bot himoyasi — Turnstile siteverify (X-Captcha),"
M+=" honeypot maydoni, SMS kunlik global byudjeti"
git add src/auth src/config.ts src/server.ts test/bot.test.ts
git commit -m "$M"

11. Real ishda

  • Cloudflare Turnstile, hCaptcha, reCAPTCHA — eng keng tarqalgan uchtasi; hammasida server tomoni siteverify ga o'xshash bitta POST.
  • SMS provayderlari (Twilio va boshqalar) SMS pumping'ni alohida xavf sifatida hujjatlaydi va mamlakat ro'yxati, chegaralar va "fraud guard" xizmatlarini taklif qiladi. O'zbekistondagi provayderlarda ham kunlik limit sozlamasi bo'lishi mumkin — shartnomadan tekshiring.
  • Bot boshqaruvi katta saytlarda alohida mahsulot: xulq tahlili, qurilma izi, IP obro'si. CAPTCHA — uning faqat bir qismi.
  • Intervyuda: "CAPTCHA tokenini qanday tekshirasiz?", "Honeypot nima va nega 200 qaytaradi?", "SMS pumping nima?", "Fail open va fail closed qachon?".

Xulosa

  • CAPTCHA tokeni faqat serverda siteverify orqali tasdiqlanadi; qaror — success === true bo'yicha, HTTP status bo'yicha emas.
  • Rasmiy test kaliti hostname: "example.com" qaytaradi — domen tekshiruvi bilan birga ishlatib bo'lmaydi.
  • Tarmoq xatosida CAPTCHA — yopiq yiqilish; yo'lni tanlash — "rad etish" va "o'tkazish" narxini solishtirish.
  • Asal idish: maydon sxemada, bot odam bilan aynan bir xil 202 oladi, farq faqat jurnalda.
  • SMS pumping — pulga qarshi hujum; to'rt qatlam, oxirgisi — kunlik global byudjet va jurnal signali.

Keyingi dars: OWASP API Security Top 10 — qism yakuni: API1–API10 ro'yxati bo'yicha vazifalar-api auditi, 28-qismda topilgan haqiqiy zaifliklar jadvali, regressiya testlari va frontend'ni haqiqiy kirishga ulash.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
CAPTCHA tokenini serverda tekshirish: Turnstile siteverify, asal idish va SMS pumping — IlmHamroh