Mundarija (20)
- Bu darsda
- 1. Nega bu kerak?
- 2. Sxema: obuna va yetkazish
- 3. Hodisani navbatga qo'yish
- 4. Worker: claim va yuborish
- 5. Javob statuslari
- 6. Qayta urinish jadvali va jitter
- 7. SSRF: foydalanuvchi bergan URL
- 7.1 URL qoidalari
- 7.2 IP ro'yxati
- 7.3 DNS rebinding: qachon tekshirish
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): qaror
- 2-mashq (o'rta): yetkazish natijasi
- 3-mashq (qiyin): IPv4 tarmoq tekshiruvi qo'lda
- 10. Loyiha qadami: chiquvchi.ts va ssrf.ts
- 11. Real ishda
- Xulosa
- Manbalar
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 uniFOR UPDATE SKIP LOCKEDbilan 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.tsvawebhook/ssrf.tsmodullarini 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):
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:
/**
* 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
faolva 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 chaqiruv1, ikkinchisi0.- Bitta hodisa — bitta
webhook-idhamma 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):
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.sirUch g'oya bitta so'rovda:
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.- Egallash paytida
keyingi_urinishkelajakka suriladi (so'rov timeout'i + 30 soniya). Worker yuborish o'rtasida o'lsa, qatoryuborilmoqdada qolib ketmaydi: muddat o'tgach u yana tanlanadi — shuning uchun shartda'yuborilmoqda'ham bor. 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:
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:
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:
4000 6000
600000
undefined
117755 s = 32.7 soat- Birinchi urinishdan keyin 4–6 soniya (5 ± 20 %). Kanon testi ham
tasodifni() => 0va() => 1qilib 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:
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:
ok: hooks.example.uz
rad: faqat https
rad: login/parol
rad: port 6379
ok: 127.0.0.1
ok: 127.0.0.1Oxirgi 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:
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:
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 falseBu 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:
/**
* 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):
- 204 No Content.
- 301 Moved Permanently.
- 410 Gone.
- 503, bu 3-urinish.
- 500, bu 9-urinish.
Yechim
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
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:
{ 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
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:
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 trueBitli >>> 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):
// 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:
// 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";
}
}/**
* 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:
✔ 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.0156Nima 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:
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_yetkazishlarjadvali 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 LOCKEDbilan egallaydi,keyingi_urinishni surib,urinishlarni 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 agentlookupichida — DNS rebinding oynasi yo'q.
Keyingi dars: Refund, reconciliation va moliyaviy hisob — qisman qaytarish, provayder hisoboti bilan solishtirish va ikki yoqlama ledger.
Manbalar
- Standard Webhooks (yuborish tavsiyalari, qayta urinish): https://www.standardwebhooks.com
- OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- undici
Agent(connect.lookup): https://undici.nodejs.org/#/docs/api/Agent - Node
net.BlockList: https://nodejs.org/api/net.html#class-netblocklist - PostgreSQL
SKIP LOCKED: https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE - AWS, eksponensial kutish va jitter: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Svix: https://www.svix.com — sinalmagan
- Kanon (IlmHamroh, 2026-10-27):
modullar/src/webhook/{chiquvchi,ssrf}.ts,sql/203-webhooklar.sql, undici 8.12.3, Node 24.21.0. Testlar — biz ishga tushirdik, PostgreSQL 17.6 (2026-10-28).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!