IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime35/69-dars23 daqiqa
Mundarija (20)

Chiquvchi webhook'larni yuborish: qayta urinish, DLQ va SSRF himoyasi

Qisqacha: Endi biz yuboruvchimiz: foydalanuvchi o'z URL'ini ro'yxatdan o'tkazadi, vazifa hodisalari unga imzolanib yuboriladi. "Bitta fetch" yetmaydi: mijoz serveri o'chiq, sekin yoki xato beradi. Kanon har yetkazishni bazada jurnal qatori qilib saqladi: worker'lar uni FOR UPDATE SKIP LOCKED bilan oladi, xatoda eksponensial kutish + jitter bilan 9 marta (5 soniyadan 12 soatgacha) qayta uradi, jadval tugasa — DLQ (tashlandi). 2xx — muvaffaqiyat, 3xx — yo'q, 410 — obuna o'chadi. Foydalanuvchi bergan URL — SSRF xavfi: kanon IP manzilni DNS javobidan keyin, aynan ulanish paytida tekshiradi.

Bu darsda

  • 25-qismdagi "fire-and-forget" webhook nega yetarli emasligini ko'rasiz.
  • Yetkazish jurnali (webhook_obunalari, webhook_yetkazishlar) sxemasini o'qiysiz.
  • Qayta urinish jadvali, jitter va DLQ ni hisoblaysiz.
  • Qaysi HTTP status nimani anglatishini (2xx, 3xx, 410, 5xx) qaror qilasiz.
  • SSRF hujumini va uning himoyasini (URL qoidalari, IP ro'yxati, DNS rebinding) tushunasiz.
  • webhook/chiquvchi.ts va webhook/ssrf.ts modullarini yozasiz.

Oldin bilishingiz kerak: Kiruvchi webhook'larni qabul qilish, SKIP LOCKED bilan DB-asosli navbat, Built-in fetch va undici, net, dns va tls: TCP darajasida Node, timers va timers/promises chuqur (eksponensial kutish).

Oldingi darsda qabul qiluvchi tomonda turdik. Endi stolni aylantiramiz: qabul qiluvchi qanday xatolar qilishini bilgan holda, biz qanday yuboruvchi bo'lishimiz kerakligini ko'ramiz.

1. Nega bu kerak?

vazifalar-api da 25-qismda bitta global WEBHOOK_URL bor edi: vazifa o'zgarganda fetch chaqirilardi, natijasi kutilmasdi ("fire-and-forget"). Muammolar:

Holat Fire-and-forget'da
mijoz serveri 5 daqiqa o'chiq hodisa abadiy yo'qoladi
server qayta ishga tushdi yo'lda turgan hodisalar yo'qoladi
mijoz URL'i http://169.254.169.254/ server ichki tarmoqqa so'rov yuboradi
URL egasi uni o'chirib yubordi har hodisa — behuda so'rov, abadiy
"bu hodisa yetib bordimi?" javob yo'q

Kanon bu o'rniga to'liq tizim qurdi: har foydalanuvchiga o'z obunasi va siri, har yetkazish — bazadagi qator, worker, qayta urinish, jurnal va SSRF himoyasi. vazifalar-api ga ulash — 28-qism (auth) tugagach.

Diqqat: Haqiqiy tashqi mijozga webhook yuborilmagan. Kanon testi lokal HTTP qabul qiluvchi (127.0.0.1) va Postgres bilan ishladi. Svix kabi tayyor xizmatlar — tanishtiruv uchun, sinalmagan.

2. Sxema: obuna va yetkazish

sql/203-webhooklar.sql (izohlar qisqartirilgan, ustunlar kanondagidek):

sql
CREATE TABLE webhook_obunalari (
  id               bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  foydalanuvchi_id bigint NOT NULL
    REFERENCES foydalanuvchilar (id) ON DELETE CASCADE,
  url       text NOT NULL
    CHECK (url ~ '^https?://' AND char_length(url) <= 2000),
  sir       text NOT NULL,   -- whsec_<base64>, bir marta ko'rsatiladi
  hodisalar text[] NOT NULL DEFAULT '{vazifa.yaratildi,
    vazifa.yangilandi,vazifa.bajarildi,vazifa.ochirildi}',
  faol            boolean     NOT NULL DEFAULT true,
  ketma_ket_xato  int         NOT NULL DEFAULT 0,
  yaratilgan_vaqt timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE webhook_yetkazishlar (
  id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  obuna_id        bigint NOT NULL
    REFERENCES webhook_obunalari (id) ON DELETE CASCADE,
  hodisa_id       text   NOT NULL,  -- webhook-id sarlavhasi
  tur             text   NOT NULL,
  tana            jsonb  NOT NULL,
  holat text NOT NULL DEFAULT 'navbatda' CHECK (holat IN
    ('navbatda', 'yuborilmoqda', 'yuborildi', 'tashlandi')),
  urinishlar      int         NOT NULL DEFAULT 0,
  keyingi_urinish timestamptz NOT NULL DEFAULT now(),
  oxirgi_status   int,
  oxirgi_xato     text,
  yaratilgan_vaqt timestamptz NOT NULL DEFAULT now(),
  yuborilgan_vaqt timestamptz,
  CONSTRAINT webhook_yetkazishlar_hodisa_key
    UNIQUE (obuna_id, hodisa_id)
);
CREATE INDEX webhook_yetkazishlar_navbat_idx
  ON webhook_yetkazishlar (keyingi_urinish)
  WHERE holat = 'navbatda';

Fayl oxirida RLS (Row Level Security): foydalanuvchi faqat o'z obunalarini va yetkazishlarini ko'radi; yetkazish qatorlarini faqat server (worker) yozadi.

Yetkazish holatlari:

stateDiagram-v2
  [*] --> navbatda
  navbatda --> yuborilmoqda: worker oldi
  yuborilmoqda --> yuborildi: 2xx
  yuborilmoqda --> navbatda: xato, jadvalda urinish bor
  yuborilmoqda --> tashlandi: jadval tugadi yoki 410
  yuborilmoqda --> yuborilmoqda: worker o'ldi, muddat o'tdi
  yuborildi --> [*]
  tashlandi --> [*]

tashlandi — DLQ (dead letter queue, "o'lik xatlar navbati"): yetkazib bo'lmagan hodisalar o'chirilmaydi, jurnalda qoladi. Ularni ko'rish, sababini bilish va qo'lda qayta yuborish mumkin.

3. Hodisani navbatga qo'yish

hodisaQosh bitta INSERT … SELECT bilan hodisani shu foydalanuvchining hamma mos obunalariga yozadi:

ts
/**
 * Hodisani shu foydalanuvchining tegishli obunalariga navbatga
 * qo'yadi. Bitta hodisa — bitta `webhook-id` (hamma obunaga bir xil).
 */
export async function hodisaQosh(
  pool: Pool,
  foydalanuvchiId: number,
  tur: string,
  malumot: unknown,
  hodisaId: string = randomUUID(),
): Promise<number> {
  const tana = {
    type: tur,
    timestamp: new Date().toISOString(),
    data: malumot,
  };
  const r = await pool.query(
    `INSERT INTO webhook_yetkazishlar (obuna_id, hodisa_id, tur, tana)
     SELECT id, $3, $2, $4 FROM webhook_obunalari
      WHERE foydalanuvchi_id = $1 AND faol AND $2 = ANY(hodisalar)
     ON CONFLICT (obuna_id, hodisa_id) DO NOTHING`,
    [foydalanuvchiId, tur, hodisaId, tana],
  );
  return r.rowCount ?? 0;
}
  • Faqat faol va shu hodisa turiga obuna bo'lganlar ($2 = ANY(hodisalar)).
  • ON CONFLICT (obuna_id, hodisa_id) DO NOTHING — o'sha hodisa ikki marta qo'shilsa ham, bitta yetkazish. Kanon testi: birinchi chaqiruv 1, ikkinchisi 0.
  • Bitta hodisa — bitta webhook-id hamma obunalar uchun. Qabul qiluvchi shu id bo'yicha takrorni tashlaydi (oldingi dars).

Muhim: hodisaQosh vazifa o'zgargan o'sha tranzaksiyada chaqirilsa, "vazifa saqlandi, webhook yo'qoldi" holati bo'lmaydi. Bu g'oya — Transactional outbox darsida to'liq.

4. Worker: claim va yuborish

Avval bitta hodisaning butun yo'lini ko'raylik — kim nima qiladi:

Qadam Kim Nima qiladi
1 bizning API vazifa o'zgardi → hodisaQosh (qator navbatda)
2 worker vaqti kelgan qatorni egallaydi (yuborilmoqda)
3 worker → mijoz serveri imzolangan POST
4 mijoz serveri → worker status kodi (yoki javob yo'q)
5 worker qatorni yuborildi, navbatda yoki tashlandi qiladi

So'rovni foydalanuvchi emas, worker yuboradi. Shuning uchun mijoz serveri sekin bo'lsa ham, bizning API tez javob beraveradi.

yetkazishIshi — bir "aylanish": vaqti kelgan qatorlarni oladi va yuboradi. Uni rejali vazifa yoki navbat har necha soniyada chaqiradi. Eng muhim qismi — qatorlarni egallash (claim):

sql
UPDATE webhook_yetkazishlar y
   SET holat = 'yuborilmoqda', urinishlar = urinishlar + 1,
       keyingi_urinish = now() + make_interval(secs => $2)
  FROM webhook_obunalari o
 WHERE y.id IN (
         SELECT id FROM webhook_yetkazishlar
          WHERE holat IN ('navbatda', 'yuborilmoqda')
            AND keyingi_urinish <= now()
          ORDER BY keyingi_urinish
          LIMIT $1 FOR UPDATE SKIP LOCKED)
   AND o.id = y.obuna_id
RETURNING y.id, y.obuna_id, y.hodisa_id, y.tana,
          y.urinishlar, o.url, o.sir

Uch g'oya bitta so'rovda:

  1. FOR UPDATE SKIP LOCKED (SKIP LOCKED bilan DB-asosli navbat) — bir nechta worker parallel ishlasa, har biri boshqa qatorlarni oladi: qulflangan qator o'tkazib yuboriladi, kutilmaydi.
  2. Egallash paytida keyingi_urinish kelajakka suriladi (so'rov timeout'i + 30 soniya). Worker yuborish o'rtasida o'lsa, qator yuborilmoqda da qolib ketmaydi: muddat o'tgach u yana tanlanadi — shuning uchun shartda 'yuborilmoqda' ham bor.
  3. urinishlar + 1 — darhol, yuborishdan oldin. Worker o'lsa ham urinish hisoblanadi va jadval cheksiz aylanmaydi.

Keyin har qator uchun so'rov — imzolangan (oldingi darsdagi sarlavhalar), redirect: "manual" va timeout bilan:

ts
const javob = await undiciFetch(u, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "user-agent": "vazifalar-webhook/1",
    ...sarlavhalar(q.sir, q.hodisa_id, tana),
  },
  body: tana,
  redirect: "manual",
  dispatcher: o.agent,
  signal: AbortSignal.timeout(kutish),
});

5. Javob statuslari

Javob Qaror Nega
2xx yuborildi, obunaning xato hisoblagichi 0 qabul qilindi
3xx muvaffaqiyat emas — qayta urinish yo'naltirishga ergashmaymiz (SSRF)
410 Gone tashlandi va obuna o'chadi "bu manzil endi yo'q" — mijozning aniq signali
4xx, 5xx, timeout, tarmoq xatosi jadval bo'yicha qayta urinish vaqtinchalik bo'lishi mumkin
jadval tugadi tashlandi (DLQ) cheksiz urinmaymiz
20 ta ketma-ket xato obuna faol = false o'lik manzilga behuda yuk

3xx qarori xavfsizlikdan kelib chiqqan: redirect: "manual" bo'lmasa, fetch yo'naltirishga avtomatik ergashadi. Hujumchi o'z serverida 302 Location: http://169.254.169.254/ qaytarsa — so'rovingiz bulut serverining ichki metama'lumot manziliga ketadi. Kanon testi aynan shuni sinadi: qabul qiluvchi 302 bilan 169.254.169.254 ga yo'naltirdi — yetkazish qayta deb hisoblandi, yo'naltirilgan manzilga hech qanday so'rov bormadi.

6. Qayta urinish jadvali va jitter

Kanon jadvali (soniya): 5, 30, 120, 600, 1800, 7200, 21600, 43200, 43200 — to'qqizta urinish. Har kechikishga ±20 % jitter (tasodifiy siljish) qo'shiladi:

js
const JADVAL_SONIYA = [
  5, 30, 120, 600, 1800, 7200, 21600, 43200, 43200,
];

function kechikishMs(urinish, tasodif = Math.random) {
  const s = JADVAL_SONIYA[urinish - 1];
  if (s === undefined) return undefined;
  return Math.round(s * 1000 * (0.8 + 0.4 * tasodif()));
}

console.log(kechikishMs(1, () => 0), kechikishMs(1, () => 1));
console.log(kechikishMs(4, () => 0.5));
console.log(kechikishMs(10));
const jami = JADVAL_SONIYA.reduce((a, b) => a + b, 0);
console.log(jami, "s =", (jami / 3600).toFixed(1), "soat");

Konsolda:

text
4000 6000
600000
undefined
117755 s = 32.7 soat
  • Birinchi urinishdan keyin 4–6 soniya (5 ± 20 %). Kanon testi ham tasodif ni () => 0 va () => 1 qilib aynan shu chegaralarni tekshiradi.
  • 10-urinish uchun jadvalda joy yo'q — undefined → DLQ.
  • Jami kutish — 32.7 soat. Mijoz serveri bir kecha o'chiq tursa ham, hodisa ertasi kuni yetib boradi. (Pastdagi modul izohida "~1.5 sutka" deyilgan — bu yaxlitlangan taxmin; aniq hisob shu 32.7 soat.)

Nega jitter? Sizning tizimingiz mijoz serveri o'chgan 10 daqiqa ichida 5 000 hodisa to'pladi. Jittersiz hammasi aynan bir soniyada qayta uriladi — tiklanayotgan serverni yana yiqitadi (thundering herd, "podaning dupuri"). Jitter ularni vaqt bo'ylab sochadi. Bu g'oyani timers va timers/promises chuqur darsidagi eksponensial kutishdan bilasiz — bu yerda u bazadagi keyingi_urinish ustunida yashaydi.

7. SSRF: foydalanuvchi bergan URL

SSRF (Server-Side Request Forgery) — hujumchi serveringizni o'zi xohlagan manzilga so'rov yuborishga majburlaydi. Webhook URL'i — klassik SSRF eshigi: "mening URL'im — http://localhost:6379/" (Redis), "http://169.254.169.254/latest/meta-data/" (bulut kalitlari), "http://10.0.0.5/admin" (ichki panel). SSRF batafsil — 28-qismda (SSRF va tashqi URL'lar bilan ishlash); bu yerda kanon himoyasining ikki qatlami.

7.1 URL qoidalari

Faqat https, URL ichida login/parol yo'q, port faqat 80/443. Brauzerdagi URL bilan sinaymiz — va bir kutilmagan narsani ko'ramiz:

js
function urlTekshir(url) {
  let u;
  try {
    u = new URL(url);
  } catch {
    throw new Error("URL noto'g'ri");
  }
  if (u.protocol !== "https:") throw new Error("faqat https");
  if (u.username || u.password) throw new Error("login/parol");
  const port = u.port ? Number(u.port) : 443;
  if (port !== 443 && port !== 80) throw new Error(`port ${port}`);
  return u.hostname;
}

for (const s of ["https://hooks.example.uz/v", "http://example.uz/h",
  "https://a:b@example.uz/h", "https://example.uz:6379/",
  "https://0x7f.1/", "https://2130706433/"]) {
  try {
    console.log("ok:", urlTekshir(s));
  } catch (e) {
    console.log("rad:", e.message);
  }
}

Konsolda:

text
ok: hooks.example.uz
rad: faqat https
rad: login/parol
rad: port 6379
ok: 127.0.0.1
ok: 127.0.0.1

Oxirgi ikki qator: 0x7f.1 va 2130706433 — IPv4 manzilning "g'alati" yozuvlari. URL ularni 127.0.0.1 ga normallashtirdi. "localhost" so'zini qidiradigan oddiy filtr ularni o'tkazib yuborardi. Saboq: xost nomini URL tahlilidan keyin tekshiring. Kanon urlTekshir shu yerda to'xtamaydi: xost IP bo'lsa, uni darhol ommaviy IP ro'yxati bilan tekshiradi — va bu ikki qator rad etiladi.

7.2 IP ro'yxati

Qaysi IP'lar "ichki"? Loopback (127.0.0.0/8, ::1), xususiy tarmoqlar (10/8, 172.16/12, 192.168/16), link-local (169.254/16 — bulut metama'lumoti), CGNAT (100.64/10), IPv6 ULA (fc00::/7) va boshqalar. Kanon Node'ning BlockList klassidan foydalanadi — bu Node API, brauzerda yo'q:

js
import { BlockList, isIP } from "node:net";

const taqiq = new BlockList();
taqiq.addSubnet("127.0.0.0", 8, "ipv4");
taqiq.addSubnet("10.0.0.0", 8, "ipv4");
taqiq.addSubnet("169.254.0.0", 16, "ipv4");
taqiq.addSubnet("192.168.0.0", 16, "ipv4");
taqiq.addSubnet("100.64.0.0", 10, "ipv4");
taqiq.addSubnet("::1", 128, "ipv6");
taqiq.addSubnet("fc00::", 7, "ipv6");

function ommaviyIpmi(ip) {
  const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
  if (m) return !taqiq.check(m[1], "ipv4");
  const tur = isIP(ip);
  if (tur === 0) return false;
  return !taqiq.check(ip, tur === 4 ? "ipv4" : "ipv6");
}

for (const ip of ["8.8.8.8", "127.0.0.1", "169.254.169.254",
  "100.64.0.1", "::1", "fd00::1", "::ffff:127.0.0.1", "salom"]) {
  console.log(ip.padEnd(17), ommaviyIpmi(ip));
}

Konsolda:

text
8.8.8.8           true
127.0.0.1         false
169.254.169.254   false
100.64.0.1        false
::1               false
fd00::1           false
::ffff:127.0.0.1  false
salom             false

Bu ixcham ro'yxat; kanondagi to'liq ro'yxat (14 ta IPv4 va 7 ta IPv6 tarmoq) pastdagi modulda. ::ffff:127.0.0.1 — IPv4 manzilning IPv6 ichidagi ko'rinishi (IPv4-mapped). Kanon uni alohida regex bilan IPv4'ga aylantirib tekshiradi. "Salom" — IP emas, false: noma'lum narsa — ommaviy emas.

7.3 DNS rebinding: qachon tekshirish

Eng nozik joy. Hujumchining domeni hook.hujumchi.uz birinchi DNS so'rovda ommaviy IP qaytaradi, ikkinchisida — 127.0.0.1 (DNS yozuvining yashash muddati 0 soniya). Kod avval dns.lookup qilib "ommaviy ekan" deydi, keyin fetch qiladi — fetch esa DNS'ni qayta so'raydi va 127.0.0.1 ga ulanadi. Bu DNS rebinding.

Kanon yechimi: tekshiruvni ulanishning o'zi ichiga qo'yish. undici Agent ga o'z lookup funksiyasi beriladi: DNS javobidagi hamma manzil tekshiriladi va aynan shu tekshirilgan manzil ulanish uchun qaytariladi — ikkinchi so'rov yo'q, oyna yo'q:

ts
/**
 * undici Agent: har ulanishda DNS javobidagi barcha manzillar ommaviy
 * bo'lishi shart. `ruxsat` — test/lokal uchun istisno (masalan
 * 127.0.0.1 mock).
 */
export function xavfsizAgent(
  o: { ruxsat?: (ip: string) => boolean; ulanishMs?: number } = {},
): Agent {
  return new Agent({
    connect: {
      timeout: o.ulanishMs ?? 5000,
      lookup: (host: string, opts: LookupOptions, cb: LookupCb) => {
        dnsLookup(
          host,
          { ...opts, all: true },
          (err, manzillar) => {
            if (err) {
              cb(err, []);
              return;
            }
            const yomon = manzillar.find(
              (a) =>
                !ommaviyIpmi(a.address) &&
                !(o.ruxsat?.(a.address) ?? false),
            );
            if (yomon) {
              cb(
                new SsrfXato(
                  `taqiqlangan manzil: ${host} → ${yomon.address}`,
                ),
                [],
              );
              return;
            }
            if (opts.all) cb(null, manzillar);
            else {
              const b = manzillar[0];
              if (!b) {
                cb(new SsrfXato(`manzil yo'q: ${host}`), []);
                return;
              }
              cb(null, b.address, b.family);
            }
          },
        );
      },
    },
  });
}

Kanon testi: lokal qabul qiluvchi localhost da; ruxsat ro'yxatisiz agent bilan yuborishda ulanish boshlanmadi — qabul qiluvchi hech narsa olmadi, jurnalda oxirgi_xato "taqiqlangan manzil" bo'ldi, yetkazish keyingi urinishga qoldi. Testlarda lokal qabul qiluvchiga ruxsat ruxsat sozlamasi orqali beriladi (faqat test uchun).

Tekshirib ko'ring: Nega URL qoidalari (https, port) yetarli emas va IP tekshiruvi ham kerak?

Javob

https://ichki.hujumchi.uz/ — https, 443-port, URL'da hech qanday shubhali narsa yo'q. Lekin uning DNS yozuvi 10.0.0.5 ni ko'rsatishi mumkin. Xavf URL matnida emas, u ko'rsatgan manzilda. Shuning uchun IP DNS javobidan keyin, ulanish paytida tekshiriladi.

8. Ko'p uchraydigan xatolar

Xato Oqibati To'g'risi
fire-and-forget fetch hodisalar yo'qoladi bazadagi jurnal + worker
qayta urinish jitter'siz tiklanayotgan serverni yiqitadi ±20 %
cheksiz qayta urinish o'lik manzilga abadiy yuk jadval + DLQ + obunani o'chirish
redirect: "follow" 302 orqali ichki manzilga redirect: "manual", 3xx — xato
dns.lookup keyin fetch DNS rebinding tekshiruv agent lookup ichida
"localhost" so'zini qidirish 0x7f.1, 2130706433 o'tadi URL + IP ro'yxati
ikki worker bitta qatorni oladi ikki marta yuborish FOR UPDATE SKIP LOCKED
urinishlar ni yuborishdan keyin oshirish o'lgan worker — cheksiz aylanish egallashda oshirish
imzosiz yuborish mijoz soxta so'rovni farqlay olmaydi Standard Webhooks imzosi

9. Mashqlar

Mashqlar kurs/mashqlar/29/35-chiquvchi/ da.

1-mashq (oson): qaror

Har javob uchun kanon qarorini ayting (yuborildi, qayta, tashlandi va obuna holati):

  1. 204 No Content.
  2. 301 Moved Permanently.
  3. 410 Gone.
  4. 503, bu 3-urinish.
  5. 500, bu 9-urinish.
Yechim
  1. yuborildi — har 2xx. 2. qayta — 3xx muvaffaqiyat emas, yo'naltirishga ergashilmaydi. 3. tashlandi, obuna o'chadi. 4. qayta, jadval bo'yicha (120 s ± 20 %). 5. tashlandi — jadvalda 10-urinish yo'q (DLQ).

2-mashq (o'rta): yetkazish natijasi

Kanon qaror mantiqini toza funksiya qiling: natija(status, urinish) — status son yoki null (tarmoq xatosi). Qaytarsin: { holat: "yuborildi" }, { holat: "navbatda", kechikish } (jitter'siz, JADVAL_SONIYA dan, soniyada) yoki { holat: "tashlandi", obunaOchadi }. Beshta holatni sinang.

Yechim
js
const JADVAL = [5, 30, 120, 600, 1800, 7200, 21600, 43200, 43200];

function natija(status, urinish) {
  if (status !== null && status >= 200 && status < 300) {
    return { holat: "yuborildi" };
  }
  const kechikish = status === 410 ? undefined : JADVAL[urinish - 1];
  if (kechikish === undefined) {
    return { holat: "tashlandi", obunaOchadi: status === 410 };
  }
  return { holat: "navbatda", kechikish };
}

console.log(natija(204, 1));
console.log(natija(302, 1));
console.log(natija(null, 3));
console.log(natija(410, 1));
console.log(natija(500, 9));
console.log(natija(500, 10));

Konsolda:

text
{ holat: 'yuborildi' }
{ holat: 'navbatda', kechikish: 5 }
{ holat: 'navbatda', kechikish: 120 }
{ holat: 'tashlandi', obunaOchadi: true }
{ holat: 'navbatda', kechikish: 43200 }
{ holat: 'tashlandi', obunaOchadi: false }

urinish — shu urinishning tartib raqami (egallashda oshirilgan). 9-urinish xatosidan keyin hali bitta 43 200 soniyalik kutish bor; 10-urinish xatosi — DLQ. Kanon kodida bu yerda yana obunaning ketma_ket_xato hisoblagichi ham yangilanadi.

3-mashq (qiyin): IPv4 tarmoq tekshiruvi qo'lda

BlockList siz, brauzerda ishlaydigan tarmoqdami(ip, tarmoq, prefiks) yozing: IPv4 manzillarni 32 bitli songa aylantiring va prefiks niqobi bilan solishtiring. ichkimi(ip) — ro'yxatdagi (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10) birortasida bo'lsa true. To'g'ri IPv4 bo'lmasa — true (ishonchsiz — ichki deb hisoblaymiz). Oltita manzilni sinang.

Yechim
js
function songa(ip) {
  const q = ip.split(".");
  if (q.length !== 4 || !q.every((x) => /^\d{1,3}$/.test(x)
    && Number(x) <= 255)) return null;
  return q.reduce((a, x) => a * 256 + Number(x), 0);
}

function tarmoqdami(ip, tarmoq, prefiks) {
  const blok = 2 ** (32 - prefiks);
  const a = Math.floor(songa(ip) / blok);
  return a === Math.floor(songa(tarmoq) / blok);
}

const ICHKI = [["127.0.0.0", 8], ["10.0.0.0", 8], ["172.16.0.0", 12],
  ["192.168.0.0", 16], ["169.254.0.0", 16], ["100.64.0.0", 10]];

function ichkimi(ip) {
  if (songa(ip) === null) return true;
  return ICHKI.some(([t, p]) => tarmoqdami(ip, t, p));
}

for (const ip of ["8.8.8.8", "172.31.255.1", "172.32.0.1",
  "100.127.0.1", "169.254.169.254", "300.1.1.1"]) {
  console.log(ip, ichkimi(ip));
}

Konsolda:

text
8.8.8.8 false
172.31.255.1 true
172.32.0.1 false
100.127.0.1 true
169.254.169.254 true
300.1.1.1 true

Bitli >>> o'rniga bo'lish va Math.floor ishlatdik — JavaScript bit amallari 32 bitli ishorali son bilan ishlaydi va 128.0.0.0 dan katta manzillarda chalkashlik beradi. 172.16/12 — 172.16 dan 172.31 gacha: 172.31.255.1 ichida, 172.32.0.1 tashqarida. Haqiqiy kodda IPv6 ham bor — shuning uchun kanon tayyor BlockList ni tanladi.

10. Loyiha qadami: chiquvchi.ts va ssrf.ts

chiquvchi.ts — to'liq (formatlangan nusxa, ma'nosi kanondagidek; SQL so'rovlari qatorlarga bo'lingan):

ts
// chiquvchi.ts — chiquvchi webhook'lar (sql/203): hodisa → har
// obunaga yetkazish qatori → worker yuboradi (Standard Webhooks
// imzo), qayta urinish
// (eksponensial + jitter), 410 → obuna o'chadi, jadval tugasa →
// 'tashlandi' (DLQ), SSRF himoyasi (undici Agent, ulanish paytida IP
// tekshiruvi).
import { randomUUID } from "node:crypto";
import type { Pool } from "pg";
import { fetch as undiciFetch, type Agent } from "undici";
import { sarlavhalar } from "./imzo.ts";
import { urlTekshir } from "./ssrf.ts";

/**
 * Urinishlar orasidagi kechikish (soniya): Svix/Standard Webhooks
 * tavsiyasiga yaqin — jami ~ 1.5 sutka; keyin DLQ.
 */
export const JADVAL_SONIYA = [
  5, 30, 120, 600, 1800, 7200, 21600, 43200, 43200,
] as const;
export const OBUNA_OCHIRISH_CHEGARASI = 20;

/**
 * ±20 % jitter — bir vaqtda yiqilgan minglab yetkazish bir vaqtda
 * qaytib urilmasin (thundering herd).
 */
export function kechikishMs(
  urinish: number,
  tasodif: () => number = Math.random,
): number | undefined {
  const s = JADVAL_SONIYA[urinish - 1];
  if (s === undefined) return undefined;
  return Math.round(s * 1000 * (0.8 + 0.4 * tasodif()));
}

/**
 * Hodisani shu foydalanuvchining tegishli obunalariga navbatga
 * qo'yadi. Bitta hodisa — bitta `webhook-id` (hamma obunaga bir xil).
 */
export async function hodisaQosh(
  pool: Pool,
  foydalanuvchiId: number,
  tur: string,
  malumot: unknown,
  hodisaId: string = randomUUID(),
): Promise<number> {
  const tana = {
    type: tur,
    timestamp: new Date().toISOString(),
    data: malumot,
  };
  const r = await pool.query(
    `INSERT INTO webhook_yetkazishlar (obuna_id, hodisa_id, tur, tana)
     SELECT id, $3, $2, $4 FROM webhook_obunalari
      WHERE foydalanuvchi_id = $1 AND faol AND $2 = ANY(hodisalar)
     ON CONFLICT (obuna_id, hodisa_id) DO NOTHING`,
    [foydalanuvchiId, tur, hodisaId, tana],
  );
  return r.rowCount ?? 0;
}

type Qator = {
  id: string;
  obuna_id: string;
  hodisa_id: string;
  tana: unknown;
  urinishlar: number;
  url: string;
  sir: string;
};

/**
 * Vaqti kelgan yetkazishlarni oladi va yuboradi. Ko'p worker parallel
 * ishlashi mumkin (SKIP LOCKED); o'lgan worker qoldirgan
 * 'yuborilmoqda' — muddatdan keyin qayta olinadi.
 */
export async function yetkazishIshi(
  pool: Pool,
  o: {
    agent: Agent;
    limit?: number;
    httpRuxsat?: boolean;
    portlar?: number[];
    kutishMs?: number;
    tasodif?: () => number;
  },
): Promise<{
  yuborildi: number;
  qayta: number;
  tashlandi: number;
}> {
  const kutish = o.kutishMs ?? 10_000;
  const r = await pool.query<Qator>(
    `UPDATE webhook_yetkazishlar y
        SET holat = 'yuborilmoqda', urinishlar = urinishlar + 1,
            keyingi_urinish = now() + make_interval(secs => $2)
       FROM webhook_obunalari o
      WHERE y.id IN (
              SELECT id FROM webhook_yetkazishlar
               WHERE holat IN ('navbatda', 'yuborilmoqda')
                 AND keyingi_urinish <= now()
               ORDER BY keyingi_urinish
               LIMIT $1 FOR UPDATE SKIP LOCKED)
        AND o.id = y.obuna_id
     RETURNING y.id, y.obuna_id, y.hodisa_id, y.tana,
               y.urinishlar, o.url, o.sir`,
    [o.limit ?? 50, kutish / 1000 + 30],
  );
  const hisob = { yuborildi: 0, qayta: 0, tashlandi: 0 };
  await Promise.all(
    r.rows.map(async (q) => {
      const tana = JSON.stringify(q.tana);
      let status: number | null = null;
      let xato: string | null = null;
      try {
        const u = urlTekshir(q.url, {
          ...(o.httpRuxsat === undefined
            ? {}
            : { httpRuxsat: o.httpRuxsat }),
          ...(o.portlar ? { portlar: o.portlar } : {}),
        });
        const javob = await undiciFetch(u, {
          method: "POST",
          headers: {
            "content-type": "application/json",
            "user-agent": "vazifalar-webhook/1",
            ...sarlavhalar(q.sir, q.hodisa_id, tana),
          },
          body: tana,
          // 3xx — muvaffaqiyat emas; yo'naltirish orqali ichki
          // manzilga o'tib ketmasin
          redirect: "manual",
          dispatcher: o.agent,
          signal: AbortSignal.timeout(kutish),
        });
        status = javob.status;
        // javob tanasi kerak emas, ulanishni bo'shatamiz
        await javob.body?.cancel();
      } catch (e) {
        const sabab =
          e instanceof Error && e.cause instanceof Error
            ? e.cause.message
            : e instanceof Error
              ? e.message
              : "";
        xato =
          e instanceof Error
            ? `${e.name}: ${sabab.slice(0, 200)}`
            : String(e);
      }
      const ok = status !== null && status >= 200 && status < 300;
      if (ok) {
        hisob.yuborildi++;
        await pool.query(
          "UPDATE webhook_yetkazishlar SET holat = 'yuborildi', " +
            "oxirgi_status = $2, oxirgi_xato = NULL, " +
            "yuborilgan_vaqt = now() WHERE id = $1",
          [q.id, status],
        );
        await pool.query(
          "UPDATE webhook_obunalari SET ketma_ket_xato = 0 WHERE " +
            "id = $1",
          [q.obuna_id],
        );
        return;
      }
      const kech =
        status === 410
          ? undefined
          : kechikishMs(q.urinishlar, o.tasodif);
      if (kech === undefined) {
        hisob.tashlandi++;
        await pool.query(
          "UPDATE webhook_yetkazishlar SET holat = 'tashlandi', " +
            "oxirgi_status = $2, oxirgi_xato = $3 WHERE id = $1",
          [q.id, status, xato],
        );
      } else {
        hisob.qayta++;
        await pool.query(
          "UPDATE webhook_yetkazishlar SET holat = 'navbatda', " +
            "oxirgi_status = $2, oxirgi_xato = $3, keyingi_urinish " +
            "= now() + make_interval(secs => $4) WHERE id = $1",
          [q.id, status, xato, kech / 1000],
        );
      }
      await pool.query(
        "UPDATE webhook_obunalari SET ketma_ket_xato = " +
          "ketma_ket_xato + 1, faol = faol AND $2 AND " +
          "ketma_ket_xato + 1 < $3 WHERE id = $1",
        [q.obuna_id, status !== 410, OBUNA_OCHIRISH_CHEGARASI],
      );
    }),
  );
  return hisob;
}

ssrf.ts ning qolgan qismi — IP ro'yxati, ommaviyIpmi, SsrfXato va urlTekshir:

ts
// ssrf.ts — foydalanuvchi bergan URL'ga server so'rov yuborishdan
// oldin: faqat ommaviy IP'lar.
// Tekshiruv ULANISH paytida (undici Agent `connect.lookup`): DNS
// javobi tekshiriladi va AYNAN shu IP'ga ulaniladi — DNS rebinding
// (avval ommaviy, keyin 127.0.0.1)
// oynasi yopiladi. URL'ni oldindan `dns.lookup` qilib, keyin fetch
// qilish — rebinding'ga ochiq (ikki marta so'raladi).
import {
  lookup as dnsLookup,
  type LookupAddress,
  type LookupOptions,
} from "node:dns";
import { BlockList, isIP } from "node:net";
import { Agent } from "undici";

const taqiq = new BlockList();
for (const [tarmoq, prefiks] of [
  ["0.0.0.0", 8],
  ["10.0.0.0", 8],
  ["100.64.0.0", 10],
  ["127.0.0.0", 8],
  ["169.254.0.0", 16],
  ["172.16.0.0", 12],
  ["192.0.0.0", 24],
  ["192.0.2.0", 24],
  ["192.168.0.0", 16],
  ["198.18.0.0", 15],
  ["198.51.100.0", 24],
  ["203.0.113.0", 24],
  ["224.0.0.0", 4],
  ["240.0.0.0", 4],
] as const)
  taqiq.addSubnet(tarmoq, prefiks, "ipv4");
for (const [tarmoq, prefiks] of [
  ["::", 128],
  ["::1", 128],
  ["fc00::", 7],
  ["fe80::", 10],
  ["ff00::", 8],
  ["2001:db8::", 32],
  ["64:ff9b::", 96],
] as const)
  taqiq.addSubnet(tarmoq, prefiks, "ipv6");

/** IPv4-mapped IPv6 (::ffff:127.0.0.1) ham tekshiriladi. */
export function ommaviyIpmi(ip: string): boolean {
  const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
  const v4 = m?.[1];
  if (v4) return !taqiq.check(v4, "ipv4");
  const tur = isIP(ip);
  if (tur === 0) return false;
  return !taqiq.check(ip, tur === 4 ? "ipv4" : "ipv6");
}

export class SsrfXato extends Error {
  readonly code = "SSRF_TAQIQ";
  constructor(xabar: string) {
    super(xabar);
    this.name = "SsrfXato";
  }
}
ts
/**
 * URL shakli: faqat http(s), foydalanuvchi:parol yo'q, port — 80/443
 * yoki ruxsat etilgan. IP literal bo'lsa — darhol tekshiriladi.
 */
export function urlTekshir(
  url: string,
  o: { httpRuxsat?: boolean; portlar?: number[] } = {},
): URL {
  let u: URL;
  try {
    u = new URL(url);
  } catch {
    throw new SsrfXato("URL noto'g'ri");
  }
  if (
    u.protocol !== "https:" &&
    !(o.httpRuxsat && u.protocol === "http:")
  )
    throw new SsrfXato("faqat https");
  if (u.username || u.password)
    throw new SsrfXato("URL ichida login/parol");
  const port = u.port
    ? Number(u.port)
    : u.protocol === "https:"
      ? 443
      : 80;
  if (!(o.portlar ?? [80, 443]).includes(port))
    throw new SsrfXato(`port ${String(port)} taqiqlangan`);
  const host = u.hostname.replace(/^\[|\]$/g, "");
  if (isIP(host) && !ommaviyIpmi(host))
    throw new SsrfXato(`taqiqlangan IP: ${host}`);
  return u;
}

Testlar — modullar/test/webhook.test.ts (oldingi darsdagi fayl). Bu darsga tegishlilari beshta. Ikkitasi bazasiz: SSRF IP ro'yxati va URL qoidalari, kechikish jadvali. Uchtasi Postgres va lokal qabul qiluvchi bilan: yuborildi (imzo qabul qiluvchida tekshirildi, takror hodisa navbatga tushmadi), 500/302/410/DLQ, localhost'ga ulanish to'xtatildi. Biz ishga tushirgan natija (Node 24.21.0, Postgres 17.6) — uchinchi qatordan boshlab qarang:

text
✔ imzo: standardwebhooks 1.1.1 bilan ikki tomonlama mos (5.4168ms)
✔ imzo: o'zgartirilgan tana, boshqa sir, eski/kelajak vaqt, sarlavhasiz (0.5404ms)
✔ SSRF: IP ro'yxati va URL qoidalari (6.2427ms)
✔ kechikish jadvali: jitter ±20 %, oxirida undefined (DLQ) (0.2222ms)
▶ chiquvchi yetkazish (Postgres + lokal qabul qiluvchi)
  ✔ yuborildi: imzo qabul qiluvchida tekshiriladi; hodisa_id bir xil bo'lsa takror navbatga tushmaydi (119.3479ms)
  ✔ 500 → qayta (urinishlar, keyingi vaqt); 302 → muvaffaqiyat EMAS; 410 → obuna o'chadi; jadval tugasa — DLQ (34.6685ms)
  ✔ SSRF: localhost URL (ruxsat ro'yxatisiz agent) — ulanishdan oldin to'xtatiladi (11.2884ms)
✔ chiquvchi yetkazish (Postgres + lokal qabul qiluvchi) (3221.3033ms)
ℹ tests 7
ℹ suites 1
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7927.0156

Nima bo'ldi? Beshala test o'tdi. Eng muhimi — ▶ ostidagi uchta qator: 302 muvaffaqiyat deb hisoblanmadi, 410 obunani o'chirdi, localhost ga esa ulanish umuman boshlanmadi. Millisekundlar har ishga tushirishda boshqacha.

Kanonda (Postgres 18.6) modullar jami 82/82.

Commit xabari:

bash
M='feat(webhook): chiquvchi yetkazish — jurnal, SKIP LOCKED,'
M+=' jitter, DLQ, 410, SSRF agent'
git commit -m "$M"

11. Real ishda

  • Svix, Hookdeck, Convoy — chiquvchi webhook'larni xizmat sifatida beradi: jurnal, qayta urinish, mijoz uchun panel. Standard Webhooks spetsifikatsiyasi Svix'dan chiqqan; kanon sinamagan.
  • Mijoz uchun jurnal sahifasi: GitHub va Stripe har yetkazishning statusi, javobi va "Redeliver" tugmasini ko'rsatadi. webhook_yetkazishlar jadvali aynan shu sahifa uchun ma'lumot.
  • Sirni almashtirish (rotatsiya): foydalanuvchi yangi sir so'raydi — bir muddat ikkala sir bilan imzolab yuboriladi (sarlavhada ikki v1), keyin eskisi o'chiriladi.
  • Intervyuda: "Webhook yuborish tizimini qanday loyihalaysiz?", "SSRF nima va webhook'da qanday himoyalanasiz?", "Nega jitter?"

Xulosa

  • Fire-and-forget yetmaydi: har yetkazish — bazadagi qator (holat, urinishlar, keyingi urinish, oxirgi status).
  • Worker FOR UPDATE SKIP LOCKED bilan egallaydi, keyingi_urinish ni surib, urinishlar ni oldindan oshirib — o'lgan worker qatori qaytib olinadi.
  • Jadval 5 s … 12 soat, 9 urinish, ±20 % jitter, jami 32.7 soat; keyin DLQ (tashlandi).
  • 2xx — yuborildi; 3xx — xato (redirect: "manual"); 410 — obuna o'chadi; 20 ketma-ket xato — obuna faolsiz.
  • SSRF: https, login/parolsiz, 80/443; IP ro'yxati (BlockList, IPv4-mapped); tekshiruv undici agent lookup ichida — DNS rebinding oynasi yo'q.

Keyingi dars: Refund, reconciliation va moliyaviy hisob — qisman qaytarish, provayder hisoboti bilan solishtirish va ikki yoqlama ledger.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!