IlmHamroh
JavaScript Full-stack/28-qism. Autentifikatsiya va backend xavfsizligi28/31-dars25 daqiqa
Mundarija (35)

SSRF va tashqi URL'lar bilan ishlash: ichki IP, DNS rebinding va xavfsiz webhook

Qisqacha: SSRF (Server-Side Request Forgery) — hujumchi serveringizni o'zi tanlagan manzilga so'rov yuborishga majburlaydi: 127.0.0.1 dagi Redis, ichki admin panel yoki bulutning 169.254.169.254 metama'lumot xizmati. Server foydalanuvchi bergan URL'ga so'rov yuboradigan har joy (webhook, rasm-URL, havola ko'rinishi) — ichki tarmoqqa eshik. Himoya to'rt qatlamli: URL qoidalari, DNS javobidagi har IP tekshiruvi, ulanishni aynan tekshirilgan IP'ga bog'lash (DNS rebinding'ga qarshi) va har redirect'ni qayta tekshirish. Kurs tajribasida vazifalar-api webhook'i tekshiruvsiz fetch edi — bu darsda u xavfsizPost orqali yuboriladi.

Bu darsda

  • SSRF nima ekanini va qaysi nishonlarga qaratilishini tushuntirasiz.
  • localhost so'zini qidiradigan filtr nega ishlamasligini URL hiylalari bilan ko'rsatasiz.
  • net.BlockList bilan ichki manzillar ro'yxatini tuzasiz.
  • DNS rebinding va redirect tuzog'ini tushunib, ularga qarshi ulanishni IP'ga bog'laysiz.
  • vazifalar-api ga xavfsiz-url.ts modulini va 4 ta SSRF testini qo'shasiz.

Oldin bilishingiz kerak: net, dns va tls: TCP darajasida Node, Built-in fetch va undici, OWASP Top 10:2025 xaritasi, Backend'dagi injection va mass assignment.

Oldingi darsda hujumchi matnni kodga aylantirishga urindi: SQL, sarlavha, prototip. Bugungi hujum boshqacha: hujumchi kod yubormaydi, faqat manzil yuboradi. Qolganini server o'zi bajaradi.

1. Nega bu kerak?

Mehmonxona qabulxonasini tasavvur qiling. Siz ichkariga, xodimlar xonasiga kira olmaysiz. Lekin qabulxonachiga "iltimos, 3-qavatdagi xodimlar xonasidan papkani olib bering" desangiz — u boradi va olib keladi. Unga ichkariga kirish mumkin, sizga emas. U kimning iltimosini bajarayotganini tekshirmadi.

SSRF — xuddi shu. Serveringiz ichki tarmoqda turadi: u bazaga, Redis'ga, ichki xizmatlarga va bulut provayderining maxfiy manziliga yeta oladi. Hujumchi esa — yo'q. Lekin server "mana bu URL'ga so'rov yubor" degan buyruqni bajarsa, hujumchi uning qo'li bilan ichkariga yetadi.

vazifalar-api da shunday joy bor: vazifa bajarilganda server WEBHOOK_URL ga xabar yuboradi (Kiruvchi webhook'larni qabul qilish mavzusining teskari tomoni). 27-qism oxirida bu kod shunday edi:

ts
fetchFn(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ hodisa, vaqt, vazifa }),
  signal: AbortSignal.timeout(kutishMs),
})

Hech qanday tekshiruv yo'q. Hozircha URL .env dan, ya'ni admin qo'lida. Lekin 29-qismda har foydalanuvchi o'z webhook va bot manzilini kiritadi. O'sha kuni bu qator hujum eshigiga aylanadi. Kanon himoyani oldindan qo'ydi — bugun biz ham shunday qilamiz.

2. SSRF qanday ishlaydi

2.1 Hujum ketma-ketligi

sequenceDiagram
  participant H as Hujumchi
  participant A as vazifalar-api
  participant M as 169.254.169.254
  H->>A: webhook URL = http://169.254.169.254/...
  Note over A: URL tekshirilmadi
  A->>M: POST (ichki tarmoqdan)
  M-->>A: javob
  A-->>H: status, xato matni yoki vaqt

Server so'rovni o'z nomidan yuboradi. Ichki xizmat uni "o'zimizniki" deb qabul qiladi: ko'pchilik ichki xizmatlarda parol yo'q, chunki "tashqaridan baribir kirib bo'lmaydi".

2.2 Nishonlar

Nishon Manzil Nima bor
bulut metama'lumoti 169.254.169.254 serverning vaqtinchalik kalitlari
shu server 127.0.0.1:6379, :5432 Redis, Postgres
ichki tarmoq 10.x, 192.168.x admin panel, monitoring
fayl tizimi file:///etc/passwd server fayllari

Birinchi qator eng xavflisi. Bulut provayderlari (AWS, Google Cloud, Azure) har serverga 169.254.169.254 manzilida metama'lumot xizmatini beradi: serverning nomi, tarmog'i va ba'zan uning vaqtinchalik kirish kalitlari. Bu manzil faqat serverning ichidan ochiladi — tashqaridan emas. SSRF esa aynan "ichidan" so'rov.

AWS buning uchun ikkinchi versiyani (IMDSv2) chiqargan: kalit olishdan oldin PUT so'rovi bilan maxsus token olish kerak. Oddiy SSRF ko'pincha faqat GET yoki POST yuboradi va sarlavhani boshqara olmaydi — IMDSv2 uni to'xtatadi. Lekin bu bulut sozlamasi, sizning kodingiz emas. Kod darajasida himoya baribir kerak.

2.3 Javobni ko'rmasa ham

"Webhook javobini foydalanuvchiga ko'rsatmaymiz — demak xavf yo'q" — xato fikr. Bunday holatni ko'r SSRF (blind SSRF) deyishadi. Hujumchi javobni ko'rmasa ham ikki narsani biladi:

  • xato yoki muvaffaqiyat — "yuborildi" / "ulanib bo'lmadi";
  • vaqt — yopiq port darhol rad etadi, mavjud bo'lmagan manzil esa timeout'gacha kutadi.

Shu farqlar bilan ichki tarmoqni "xaritalash" mumkin: qaysi IP'da qaysi port ochiq. Bundan tashqari, ichki xizmatga POST yuborishning o'zi zarar qilishi mumkin — masalan, himoyasiz ichki API'da "keshni tozalash" amali.

Tekshirib ko'ring: SSRF 2025-yilgi OWASP ro'yxatida qaysi toifa ichida va nega?

Javob

A01 Broken Access Control ichida. Mohiyatan bu ruxsat tekshiruvi yo'qligi: server "bu manzilga so'rov yuborishga menga ruxsat bormi?" deb so'ramaydi. OWASP Top 10:2025 xaritasi darsida 2021-yildagi alohida A10 toifasi shu yerga ko'chganini ko'rgansiz. API ro'yxatida esa u alohida — API7.

3. Birinchi qatlam: URL qoidalari

3.1 "localhost" ni qidirish — yetmaydi

Birinchi xayolga keladigan himoya: URL ichida localhost yoki 127.0.0.1 bo'lsa — rad etish. Brauzerdagi URL klassi bir xil manzilning boshqa yozuvlarini qanday tushunishini ko'ramiz:

js
for (const s of ["https://127.0.0.1/", "https://2130706433/",
  "https://0x7f.1/", "https://127.1/", "https://0177.0.0.1/",
  "https://0/", "https://[::ffff:127.0.0.1]/",
  "https://LOCALHOST./"]) {
  console.log(s.padEnd(30), new URL(s).hostname);
}

Konsolda:

text
https://127.0.0.1/             127.0.0.1
https://2130706433/            127.0.0.1
https://0x7f.1/                127.0.0.1
https://127.1/                 127.0.0.1
https://0177.0.0.1/            127.0.0.1
https://0/                     0.0.0.0
https://[::ffff:127.0.0.1]/    [::ffff:7f00:1]
https://LOCALHOST./            localhost.

Har qator — 127.0.0.1 ning o'zi yoki unga teng manzil:

  • 2130706433 — IPv4 manzil bitta o'nlik son sifatida (127 × 256³ + 1).
  • 0x7f.1 — o'n oltilik va qisqartirilgan yozuv; 127.1 — o'rtadagi nollar tushirilgan.
  • 0177 — sakkizlik sanoq (0177 = 127).
  • 0.0.0.0 — ko'p tizimlarda "shu kompyuter" ma'nosida ishlaydi.
  • [::ffff:7f00:1] — IPv4 manzil IPv6 ichida (IPv4-mapped), o'n oltilik yozuvda.
  • localhost. — oxirida nuqta bilan ham o'sha nom.

includes("127.0.0.1") ulardan faqat birinchisini ushlaydi. Undan tashqari, xost nomi ham ichki IP'ga ochilishi mumkin: hujumchi o'z domenini sotib olib, DNS yozuvini 127.0.0.1 ga qaratadi. Matn qidirib bunga yetib bo'lmaydi.

Shuning uchun birinchi qoida: URL'ni matn sifatida emas, tahlil qilingan holda tekshiring. new URL() bu yozuvlarni bitta kanonik ko'rinishga keltiradi (WHATWG URL standarti) — tekshiruv shundan keyin. Bu xulq brauzerda ham, Node'da ham bir xil.

3.2 Qolgan URL qoidalari

Kanon urlTekshir funksiyasi IP'ga yetmasdan oldin uchta narsani rad etadi:

Qoida Rad etiladi Nega
faqat https: http:, ftp:, file:, javascript: boshqa protokollar — boshqa nishonlar
login/parol yo'q https://user:pass@example.com/ aldov va sir oqishi
port 80 yoki 443 https://example.com:22/ SSH, Redis, bazaga "taqillatish"

http: faqat lokal sinovda (NODE_ENV production bo'lmasa) ruxsat. Port ro'yxati ham muhim: tashqi webhook odatda 443 da ishlaydi, 6379 yoki 5432 ga so'rov yuborishga sabab yo'q.

4. Ikkinchi qatlam: ichki IP ro'yxati

4.1 net.BlockList

Qaysi IP'lar "ichki"? Ularni IANA (internet manzillarini taqsimlovchi tashkilot) maxsus maqsadli manzillar registrlarida e'lon qilgan: loopback, xususiy tarmoqlar, link-local va boshqalar. Kanon ro'yxati — IPv4 da 14 ta, IPv6 da 8 ta tarmoq. Node'da buning uchun tayyor klass bor — net.BlockList (u brauzerda yo'q, shuning uchun blok faqat Node'da ishlaydi):

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

const TAQIQ = new BlockList();
const IPV4 = [
  ["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],
];
const IPV6 = [
  ["::", 128], ["::1", 128], ["64:ff9b::", 96], ["100::", 64],
  ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
];
for (const [m, p] of IPV4) TAQIQ.addSubnet(m, p, "ipv4");
for (const [m, p] of IPV6) TAQIQ.addSubnet(m, p, "ipv6");

function ichkimi(ip) {
  const tur = isIP(ip);
  if (tur === 0) return true; // IP emas — xavfsiz tomonga
  return TAQIQ.check(ip, tur === 4 ? "ipv4" : "ipv6");
}

for (const ip of ["169.254.169.254", "172.31.255.255", "172.32.0.1",
  "100.64.0.1", "fd00::1", "::ffff:127.0.0.1", "::ffff:7f00:1",
  "::ffff:8.8.8.8", "2a00:1450:4001::200e", "salom"]) {
  console.log(ip.padEnd(20), ichkimi(ip) ? "ichki" : "ommaviy");
}

Konsolda:

text
169.254.169.254      ichki
172.31.255.255       ichki
172.32.0.1           ommaviy
100.64.0.1           ichki
fd00::1              ichki
::ffff:127.0.0.1     ichki
::ffff:7f00:1        ichki
::ffff:8.8.8.8       ommaviy
2a00:1450:4001::200e ommaviy
salom                ichki

addSubnet("172.16.0.0", 12, "ipv4") — CIDR yozuvi: birinchi 12 bit bir xil bo'lgan hamma manzil. 172.16.0.0/12 — 172.16.0.0 dan 172.31.255.255 gacha. Shuning uchun 172.31.255.255 — ichki, 172.32.0.1 esa allaqachon ommaviy. Chegaradagi xato — ko'p uchraydi: "172 bilan boshlansa — ichki" degan qoida ommaviy manzillarni ham bloklaydi.

4.2 Ro'yxatdagi kam ma'lum qatorlar

  • 100.64.0.0/10 — CGNAT: provayderlar bir ommaviy IP ortida ko'p mijozni yashiradigan tarmoq. Bulutda ba'zan ichki xizmatlar shu yerda.
  • 169.254.0.0/16 — link-local, bulut metama'lumoti shu ichida.
  • 198.18.0.0/15, 192.0.2.0/24 … — sinov va hujjat uchun ajratilgan tarmoqlar; real internetda bo'lmasligi kerak.
  • 64:ff9b::/96 — NAT64: IPv6 tarmoqda IPv4 manzillarga yetish. 64:ff9b::7f00:1 aslida 127.0.0.1 ga olib boradi.
  • fc00::/7, fe80::/10 — IPv6 dagi xususiy va link-local tarmoqlar.

4.3 IPv4-mapped manzil: biz nimani tekshirdik

Kanon kodida IPv4-mapped yozuv uchun alohida qoida bor: ::ffff:a.b.c.d ni regex bilan ajratib, IPv4 ro'yxati bilan tekshiradi. Lekin yuqoridagi natijaga qarang: regex'siz ham ::ffff:127.0.0.1 va o'n oltilik ::ffff:7f00:1 — ikkalasi "ichki". Biz alohida sinadik (Node 24.21.0): BlockList IPv6 so'rovida IPv4-mapped manzilni o'zi IPv4 qoidalari bilan solishtiradi. Ya'ni kanondagi regex — zarar qilmaydigan, lekin Node'da ortiqcha qo'shimcha. Uni olib tashlamaymiz: boshqa kutubxonaga ko'chganda ham himoya qolsin.

Oxirgi qator ham muhim: "salom" — IP emas, natija "ichki", ya'ni rad. Noma'lum narsani ruxsat etilgan deb hisoblamaymiz — Xavfsizlik tamoyillari dagi "xavfsiz yiqilish" (fail securely).

Tekshirib ko'ring: Nega ichki manzillarni sanab chiqish — taqiq ro'yxati (denylist) — kerak, ommaviy manzillarning ruxsat ro'yxati (allowlist) emas?

Javob

Ommaviy internet — milliardlab manzil, ularni sanab bo'lmaydi. Webhook har qanday mijoz serveriga borishi kerak. Ichki va maxsus tarmoqlar esa cheklangan va standartlarda e'lon qilingan. Lekin agar sizga faqat bir necha ma'lum xizmat kerak bo'lsa (masalan, faqat Telegram API) — unda domenlar ruxsat ro'yxati (allowlist) eng kuchli himoya: ro'yxatdan boshqa hamma narsa rad.

5. Uchinchi qatlam: DNS va rebinding

5.1 Hamma javobni tekshirish

Webhook manzili odatda IP emas, nom: https://hook.mijoz.uz/v. Uni IP'ga aylantirish — DNS (net, dns va tls darsida dns.lookup ni ko'rgansiz). Bir nom bir nechta IP qaytarishi mumkin. Kanon dns.lookup(host, { all: true }) bilan hammasini oladi va bittasi ichki bo'lsa ham — rad etadi. Nega? Agar faqat birinchisini tekshirsak, hujumchi 93.184.216.34, 10.0.0.5 qaytaradi: tekshiruv birinchisini ko'radi, ulanish esa ikkinchisiga ketishi mumkin.

5.2 DNS rebinding

Endi eng ayyor hujum. Oddiy kod shunday ishlaydi: avval DNS so'rab IP'ni tekshiradi, keyin fetch(url) chaqiradi. Lekin fetch ham o'zi DNS so'raydi. Ikki so'rov orasida javob o'zgarishi mumkin. Hujumchi o'z DNS serverida juda qisqa umrli (TTL 0) yozuv beradi: birinchi so'rovga — ommaviy IP, ikkinchisiga — 169.254.169.254.

sequenceDiagram
  participant A as vazifalar-api
  participant D as Hujumchi DNS
  participant M as 169.254.169.254
  A->>D: hook.hujumchi.uz?
  D-->>A: 93.184.216.34
  Note over A: tekshiruv: ommaviy, ruxsat
  A->>D: hook.hujumchi.uz? (fetch o'zi)
  D-->>A: 169.254.169.254
  A->>M: POST

Bu DNS rebinding (DNS qayta bog'lash) va xatoning umumiy nomi — TOCTOU (time of check to time of use): tekshirilgan narsa va ishlatilgan narsa — boshqa-boshqa. Brauzerda soxta DNS bilan taqlid qilamiz:

js
function soxtaDns() {
  let n = 0;
  return () => (n++ === 0 ? "93.184.216.34" : "169.254.169.254");
}
const ichkimi = (ip) => ip.startsWith("169.254.");

// ❌ tekshiruv va ulanish — ikki xil DNS so'rovi
let dns = soxtaDns();
if (!ichkimi(dns())) console.log("ulanish:", dns());

// ✅ bitta so'rov: tekshirilgan IP'ning o'ziga ulanamiz
dns = soxtaDns();
const ip = dns();
if (!ichkimi(ip)) console.log("ulanish:", ip);

Konsolda:

text
ulanish: 169.254.169.254
ulanish: 93.184.216.34

Yechim mantig'i oddiy: DNS'ni bir marta so'raymiz, javobni tekshiramiz va ulanishni aynan shu IP'ga qilamiz.

5.3 Node'da: lookup opsiyasi

Lekin http.request ga IP'ni qanday "majburlaymiz"? URL'dagi nomni IP bilan almashtirsak, Host sarlavhasi va HTTPS sertifikati tekshiruvi buziladi: sertifikat hook.mijoz.uz uchun berilgan, IP uchun emas. Node'ning http.request va https.request funksiyalarida buning uchun lookup opsiyasi bor: Node nomni IP'ga aylantirmoqchi bo'lganda DNS o'rniga sizning funksiyangizni chaqiradi. Nom esa URL'da qoladi.

Lokal server bilan sinaymiz. hook.test nomi hech qayerda yo'q — DNS uni topa olmasdi. Lekin lookup "tekshirilgan" IP'ni qaytaradi:

js
import { createServer, request } from "node:http";

const server = createServer((req, res) => {
  res.end(`Host: ${req.headers.host.split(":")[0]}`);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;

const TEKSHIRILGAN = "127.0.0.1"; // urlTekshir topgan IP
let dnsSorovi = 0;
const lookup = (_host, opts, cb) => {
  dnsSorovi++;
  if (opts.all) cb(null, [{ address: TEKSHIRILGAN, family: 4 }]);
  else cb(null, TEKSHIRILGAN, 4);
};

const manzil = `http://hook.test:${port}/v`;
const req = request(manzil, { lookup }, (res) => {
  let matn = "";
  res.on("data", (b) => (matn += b));
  res.on("end", () => {
    console.log(res.statusCode, matn);
    console.log("lookup chaqirildi:", dnsSorovi);
    server.close();
  });
});
req.end();

Konsolda:

text
200 Host: hook.test
lookup chaqirildi: 1

Ikki narsani ko'ring. Server Host: hook.test ni oldi — nom saqlandi (HTTPS'da sertifikat ham shu nom bilan tekshiriladi). Va DNS bir marta ham so'ralmadi: ulanish biz bergan IP'ga ketdi. Rebinding uchun ikkinchi so'rov yo'q — hujum ishlamaydi.

lookup ning ikki shakli bor: opts.all bo'lsa massiv, aks holda bitta manzil. Node versiyasiga qarab ikkalasini ham chaqirishi mumkin, shuning uchun ikkalasini qo'llaymiz.

Maslahat: O'rnatilgan fetch (undici) da lookup opsiyasi yo'q — u uchun alohida undici Agent ini connect.lookup bilan sozlash kerak (Built-in fetch va undici). Kanon yangi bog'liqlik qo'shmaslik uchun node:http(s) ni tanladi. 29-qismning chiquvchi webhook darsida ikkinchi yo'l ham bor.

6. To'rtinchi qatlam: redirect

6.1 fetch ergashib ketadi

Tashqi server sizning so'rovingizga 307 Temporary Redirect va Location: http://169.254.169.254/... bilan javob bersa-chi? Sukut bo'yicha fetch redirect'ga o'zi ergashadi. Lokal serverda sinaymiz: /hook bizni /ichki/admin ga yuboradi:

js
import { createServer } from "node:http";

const kirish = [];
const server = createServer((req, res) => {
  kirish.push(`${req.method} ${req.url}`);
  if (req.url === "/hook") {
    res.writeHead(307, { Location: "/ichki/admin" });
    return res.end();
  }
  res.writeHead(200).end("ichki sir");
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const url = `http://127.0.0.1:${server.address().port}/hook`;

const a = await fetch(url, { method: "POST", body: "{}" });
console.log("follow:", a.status, await a.text());
const b = await fetch(url, {
  method: "POST", body: "{}", redirect: "manual",
});
console.log("manual:", b.status, b.headers.get("location"));
console.log(kirish);
server.closeAllConnections();
server.close();

Konsolda:

text
follow: 200 ichki sir
manual: 307 /ichki/admin
[ 'POST /hook', 'POST /ichki/admin', 'POST /hook' ]

Birinchi so'rov tekshirilgan /hook ga ketdi, lekin fetch jimgina ikkinchisini — tekshirilmagan manzilga ham yubordi. 307 da hatto metod va tana ham saqlanadi. Hujumchining tashqi serveri "toza" ko'rinadi, tekshiruvdan o'tadi va keyin sizni ichkariga yo'naltiradi.

redirect: "manual" bilan fetch to'xtaydi va 307 ni o'zingizga qaytaradi. Endi qaror sizda.

6.2 Kanon qoidasi

xavfsizPost redirect'ni avtomatik kuzatmaydi:

  • 307 va 308 — metod va tanani saqlaydigan redirect'lar. Yangi manzil to'liq qayta tekshiriladi (URL, DNS, IP) va ko'pi bilan 3 marta.
  • 301, 302, 303 — kuzatilmaydi, status qaytariladi. Webhook uchun ular odatda xato sozlama belgisi.

Kanon testida ikki holat sinaldi: 307 metama'lumotga yo'naltirdi — rad; 308 shu serverdagi boshqa yo'lga — kuzatildi (redirectlar: 1).

7. Webhook'dan boshqa joylar

SSRF eshigi faqat webhook emas. Server tashqi URL'ni ochadigan har joy:

  • rasmni URL'dan yuklash ("profil rasmini havoladan olish");
  • havola ko'rinishi (link preview): chatga havola tashlansa, server sahifani ochib sarlavha va rasmni oladi;
  • PDF va HTML render: server ichidagi brauzer foydalanuvchi HTML'idagi <img src> ni ochadi;
  • import: "CSV'ni shu URL'dan olib kel";
  • OAuth/OIDC — provayderning jwks_uri kabi manzillari (agar foydalanuvchi provayderni o'zi kirita olsa).

Har birida o'sha to'rt qatlam, yana ikki qo'shimcha:

  1. Javob hajmi va vaqti. Tashqi server 10 GB qaytarishi yoki javobni sekin-sekin yuborishi mumkin. Timeout va hajm chegarasi — keyingi darsning mavzusi.
  2. Tarmoq darajasida ajratish. Eng mustahkam yechim — tashqi so'rovlarni alohida, ichki tarmoqqa yo'li yo'q chiqish proksisi (egress proxy) orqali yuborish. Masalan, Stripe ochiq kodli Smokescreen proksisini chiqargan. Bu infratuzilma ishi — kanonda sinalmagan, faqat tanishib qo'ying.

8. Hujumchi nigohi

Hujumchi webhook maydonini ko'rsa, quyidagilarni ketma-ket sinaydi. O'ng ustun — kanon testida nima bo'lgani:

Hujumchi yuboradi Maqsadi xavfsizPost
http://127.0.0.1:6379/ Redis rad (protokol, port, IP)
https://2130706433/, https://0x7f.1/ yozuv hiylasi rad (URL kanonik)
https://[::ffff:127.0.0.1]/ IPv6 orqali rad (mapped)
https://user:pass@example.com/ aldov rad
https://aralash.example/ (2 IP) biri ichki rad
rebinding domeni TOCTOU ulanish 1-IP'ga
tashqi sayt → 307 → metadata redirect rad
file:///etc/passwd, javascript: boshqa protokol rad

Testlarda tashqi internetga birorta so'rov ketmagan: "tashqi" sayt — lokal server, DNS — taqlid funksiya. Haqiqiy rebinding xizmatlari bilan kanon sinamagan.

9. Ko'p uchraydigan xatolar

Xato Nega noto'g'ri To'g'risi
url.includes("localhost") 127.1, 0x7f.1, domen orqali o'tadi new URL + IP ro'yxati
faqat birinchi DNS javobini tekshirish ikkinchisi ichki bo'lishi mumkin hammasini
tekshirib, keyin fetch(url) ikkinchi DNS so'rovi (rebinding) ulanishni IP'ga bog'lash
fetch sukut redirect'i tekshirilmagan manzilga ketadi redirect: "manual", qayta tekshirish
"172" bilan boshlansa — ichki 172.32.x ommaviy CIDR (172.16.0.0/12)
IPv6 ni unutish [::1], fd00:: ikkala ro'yxat
xatoda javob matnini foydalanuvchiga qaytarish ichki javob oshkor faqat umumiy holat

10. Mashqlar

Mashqlar kurs/mashqlar/28/28-ssrf/ da.

1-mashq (oson): rad yoki ruxsat

Kanon qoidalari (faqat https, port 80/443, login/parol yo'q, ichki IP yo'q) bo'yicha har URL uchun javob bering va sababini ayting:

  1. https://hook.mijoz.uz/v1/xabar
  2. https://hook.mijoz.uz:8443/v
  3. https://0x7f.1/
  4. https://admin:123@hook.mijoz.uz/
  5. https://172.32.0.1/
Yechim
  1. Ruxsat — agar DNS javobidagi hamma IP ommaviy bo'lsa.
  2. Rad — port 8443 ro'yxatda yo'q.
  3. Rad — new URL uni 127.0.0.1 ga keltiradi, loopback.
  4. Rad — URL ichida login/parol.
  5. Ruxsat — 172.32.0.1 ommaviy: 172.16.0.0/12 oralig'i 172.31.255.255 da tugaydi.

2-mashq (o'rta): IPv4 ni o'zingiz tekshiring

BlockList brauzerda yo'q. ichkiIPv4mi(ip) funksiyasini yozing: "a.b.c.d" ni 32 bitli songa aylantirib, CIDR ro'yxati bilan solishtirsin. Ro'yxat: 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.168.0.0/16. Ishora: (a << 24 | b << 16 | c << 8 | d) >>> 0 va niqob ~((1 << (32 - n)) - 1) >>> 0. >>> 0 sonni ishorasiz qiladi.

Yechim
js
const TARMOQLAR = [["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.168.0.0", 16]];

function son(ip) {
  const [a, b, c, d] = ip.split(".").map(Number);
  return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}

function ichkiIPv4mi(ip) {
  const x = son(ip);
  return TARMOQLAR.some(([tarmoq, n]) => {
    const niqob = n === 0 ? 0 : (~((1 << (32 - n)) - 1)) >>> 0;
    return ((x & niqob) >>> 0) === son(tarmoq);
  });
}

for (const ip of ["169.254.169.254", "172.31.0.1", "172.32.0.1",
  "100.127.255.255", "100.128.0.1", "8.8.8.8"]) {
  console.log(ip.padEnd(16), ichkiIPv4mi(ip));
}

Konsolda:

text
169.254.169.254  true
172.31.0.1       true
172.32.0.1       false
100.127.255.255  true
100.128.0.1      false
8.8.8.8          false

/10 niqobi birinchi 10 bitni qoldiradi: 100.64.0.0 – 100.127.255.255. JavaScript'da bit amallari 32 bitli ishorali son qaytaradi; >>> 0 siz 192.168.x kabi manzillar manfiy son bo'lib, solishtirish buzilardi.

3-mashq (qiyin): redirect zanjiri

zanjir(boshi, javoblar, dns) funksiyasini yozing. javoblar — { "url": "keyingi url" } (redirect), dns — { "xost": "ip" }. Funksiya har qadamda URL'ni tekshirsin (faqat https:, IP — 2-mashqdagi ichkiIPv4mi bilan) va 3 tadan ortiq redirect'ga yo'l qo'ymasin. Natija: "ok: <oxirgi url>" yoki "rad: <sabab>". Uchta zanjirni sinang: toza redirect, metadata'ga redirect va cheksiz aylanish.

Yechim
js
const ichki = (ip) => /^(10|127|169\.254|192\.168)\./.test(ip);

function zanjir(boshi, javoblar, dns, maks = 3) {
  let url = boshi;
  for (let r = 0; ; r++) {
    const u = new URL(url);
    if (u.protocol !== "https:") return `rad: protokol ${u.protocol}`;
    const ip = dns[u.hostname] ?? u.hostname;
    if (ichki(ip)) return `rad: ichki ${ip}`;
    const keyingi = javoblar[url];
    if (keyingi === undefined) return `ok: ${url}`;
    if (r >= maks) return "rad: redirect juda ko'p";
    url = new URL(keyingi, url).href;
  }
}

const dns = { "a.uz": "93.184.216.34", "b.uz": "93.184.216.35" };
console.log(zanjir("https://a.uz/1",
  { "https://a.uz/1": "https://b.uz/2" }, dns));
console.log(zanjir("https://a.uz/1",
  { "https://a.uz/1": "https://169.254.169.254/latest" }, dns));
console.log(zanjir("https://a.uz/1",
  { "https://a.uz/1": "/2", "https://a.uz/2": "/1" }, dns));

Konsolda:

text
ok: https://b.uz/2
rad: ichki 169.254.169.254
rad: redirect juda ko'p

Mashq uchun ichki soddalashtirilgan — real kodda to'liq ro'yxat. Asosiy g'oya — har redirect'dan keyin hamma tekshiruv boshidan, va hisoblagich. new URL(keyingi, url) nisbiy Location ni (/2) joriy manzilga nisbatan to'liq URL'ga aylantiradi.

11. Loyiha qadami: xavfsiz-url.ts va webhook

11.1 src/umumiy/xavfsiz-url.ts

Yangi modul — to'liq. Telefon ekraniga sig'ishi uchun qatorlarni bo'ldik va boshidagi izohni qisqartirdik; mantiq kanondagidek. Biz bo'lingan nusxani kanon loyihasining nusxasida tsc, ESLint va SSRF testlari bilan tekshirdik:

ts
// xavfsiz-url.ts — SSRF himoyasi: server tashqi URL'ga (webhook,
// keyin — foydalanuvchi bergan rasm/preview URL) so'rov yuborganda
// ichki tarmoqqa (127.0.0.1, 10.x, 169.254.169.254, ::1 …) "eshik"
// bo'lmasin. Qadamlar: (1) URL: faqat http(s), login:parol yo'q, port
// ro'yxatda; (2) DNS'ni O'ZIMIZ hal qilamiz va HAR manzilni
// tekshiramiz; (3) ulanish AYNAN tekshirilgan IP'ga (lookup orqali) —
// DNS rebinding ishlamaydi; (4) redirect avtomatik kuzatilmaydi —
// har biri qayta (1)–(3). node:http(s) — `lookup` opsiyasi bilan
// (fetch/undici'da buning uchun alohida Agent kerak).
import { lookup as dnsLookup } from "node:dns/promises";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";

/** Ichki / maxsus manzillar (IANA special-purpose registrlari) */
const TAQIQ = new BlockList();
for (const [m, p] 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(m, p, "ipv4");
for (const [m, p] of [
  ["::", 128], ["::1", 128], ["64:ff9b::", 96], ["100::", 64],
  ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
] as const) TAQIQ.addSubnet(m, p, "ipv6");

export function ichkiManzilmi(ip: string): boolean {
  const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
  if (mapped?.[1] !== undefined) {
    return TAQIQ.check(mapped[1], "ipv4");
  }
  const tur = isIP(ip);
  if (tur === 0) return true; // IP emas — xavfsiz tomonga
  return TAQIQ.check(ip, tur === 4 ? "ipv4" : "ipv6");
}

export class SsrfXatosi extends Error {
  constructor(sabab: string) {
    super(`tashqi URL rad etildi: ${sabab}`);
    this.name = "SsrfXatosi";
  }
}

type Manzil = { address: string; family: number };

export type XavfsizSozlama = {
  /** sukut: faqat https; webhook lokal sinovida — http ham */
  httpRuxsat?: boolean;
  portlar?: readonly number[];
  kutishMs?: number;
  maksRedirect?: number;
  /** testda DNS taqlidi */
  dns?: (host: string) => Promise<Manzil[]>;
  /** testda: manzil taqiqlanganmi (sukut — ichkiManzilmi) */
  taqiqmi?: (ip: string) => boolean;
};

const sukutDns = (host: string) =>
  dnsLookup(host, { all: true, verbatim: true });

/** URL va DNS tekshiruvi → ulanish uchun AYNAN shu IP. Hamma
 * javob tekshiriladi: bittasi ichki bo'lsa ham — rad */
export async function urlTekshir(
  xom: string,
  s: XavfsizSozlama = {},
): Promise<{ url: URL; ip: string; oila: number }> {
  let url: URL;
  try {
    url = new URL(xom);
  } catch {
    throw new SsrfXatosi("URL emas");
  }
  const httpMumkin =
    s.httpRuxsat === true && url.protocol === "http:";
  if (url.protocol !== "https:" && !httpMumkin) {
    throw new SsrfXatosi(`protokol ${url.protocol}`);
  }
  if (url.username !== "" || url.password !== "") {
    throw new SsrfXatosi("URL ichida login/parol");
  }
  const sukutPort = url.protocol === "https:" ? 443 : 80;
  const port = url.port === "" ? sukutPort : Number(url.port);
  if (!(s.portlar ?? [80, 443]).includes(port)) {
    throw new SsrfXatosi(`port ${String(port)}`);
  }
  const host = url.hostname.replace(/^\[|\]$/g, "");
  const taqiqmi = s.taqiqmi ?? ichkiManzilmi;
  // URL parser "2130706433", "0x7f.1", "127.1" ni 127.0.0.1 ga
  // keltiradi (WHATWG) — host bu yerda allaqachon kanonik
  const manzillar = isIP(host) !== 0
    ? [{ address: host, family: isIP(host) }]
    : await (s.dns ?? sukutDns)(host).catch(() => []);
  if (manzillar.length === 0) throw new SsrfXatosi("DNS javobi yo'q");
  for (const m of manzillar) {
    if (taqiqmi(m.address)) {
      throw new SsrfXatosi(`ichki manzil ${m.address}`);
    }
  }
  const birinchi = manzillar[0];
  if (birinchi === undefined) throw new SsrfXatosi("DNS javobi yo'q");
  return { url, ip: birinchi.address, oila: birinchi.family };
}

export type XavfsizJavob = { status: number; redirectlar: number };

/** POST (webhook). Javob tanasi o'qilmaydi. Redirect: 307/308 —
 * qayta tekshirib kuzatiladi (maks 3), boshqalari — status */
export async function xavfsizPost(
  xom: string,
  tana: string,
  s: XavfsizSozlama = {},
): Promise<XavfsizJavob> {
  let manzil = xom;
  for (let r = 0; ; r++) {
    const { url, ip, oila } = await urlTekshir(manzil, s);
    // Node qaysi hostni so'rasa ham — tekshirilgan IP (rebinding:
    // ikkinchi DNS so'rovi YO'Q)
    const lookup: LookupFunction = (_h, opts, cb) => {
      if (typeof opts === "object" && opts.all === true) {
        const hammasi = cb as unknown as
          (e: null, a: Manzil[]) => void;
        hammasi(null, [{ address: ip, family: oila }]);
      } else cb(null, ip, oila);
    };
    const sorov =
      url.protocol === "https:" ? httpsRequest : httpRequest;
    const javob = await new Promise<{
      status: number;
      joy?: string | undefined;
    }>((ok, rad) => {
      const req = sorov(url, {
        method: "POST",
        lookup,
        timeout: s.kutishMs ?? 2000,
        headers: {
          "Content-Type": "application/json",
          "User-Agent": "vazifalar-api/1",
          "Content-Length": Buffer.byteLength(tana),
        },
      }, (res) => {
        res.resume();
        ok({
          status: res.statusCode ?? 0,
          joy: res.headers.location,
        });
      });
      req.on("timeout", () => {
        req.destroy(new Error("vaqt tugadi"));
      });
      req.on("error", rad);
      req.end(tana);
    });
    const qayta = javob.status === 307 || javob.status === 308;
    if (qayta && javob.joy !== undefined) {
      if (r >= (s.maksRedirect ?? 3)) {
        throw new SsrfXatosi("redirect juda ko'p");
      }
      manzil = new URL(javob.joy, url).href;
      continue;
    }
    return { status: javob.status, redirectlar: r };
  }
}

Bir nechta tafsilot:

  • url.hostname.replace(/^\[|\]$/g, "") — IPv6 manzil URL'da kvadrat qavsda keladi ([::1]), isIP esa qavssiz shaklni kutadi.
  • dns va taqiqmi — tikuv (seam, Mock, stub, spy va fake darsidagi test usuli): testda DNS'ni taqlid qilamiz va lokal sinov serveri uchun 127.0.0.1 ni vaqtincha ruxsat etamiz.
  • res.resume() — javob tanasini o'qimaymiz, lekin oqimni bo'shatamiz: aks holda ulanish band bo'lib qoladi.
  • timeout — javob kelmasa 2 soniyadan keyin so'rov uziladi. Webhook mijoz serverini kutib, bizning serverimiz osilib qolmasin.

11.2 Webhook shu orqali yuboradi

src/umumiy/webhook.ts da sukut yo'l endi xavfsizPost. fetchFn — eski testlar uchun tikuv sifatida qoladi. O'zgarishning asosiy qismi:

diff
+import { xavfsizPost } from "./xavfsiz-url.ts";
+
+type Yubor = (url: string, tana: string, kutishMs: number)
+  => Promise<{ status: number }>;

-export function webhookXabarchi(url, { fetchFn = globalThis.fetch,
-  kutishMs = 2000, log }) {
+export function webhookXabarchi(url, { fetchFn, kutishMs = 2000,
+  log, httpRuxsat = false }) {
+  const yubor: Yubor = fetchFn === undefined
+    ? (u, tana, ms) =>
+        xavfsizPost(u, tana, { kutishMs: ms, httpRuxsat })
+    : async (u, tana, ms) => { /* eski fetch yo'li (testlar) */ };

Diff'ni o'qish uchun qisqartirdik (tiplar va eski fetch yo'lining tanasi tushirib qoldirilgan). Yana bitta o'zgarish: muvaffaqiyat endi javob.ok emas, status >= 200 && status < 300 bilan aniqlanadi — xavfsizPost Response emas, faqat status qaytaradi.

src/server.ts da http: faqat production bo'lmaganda ruxsat:

ts
webhookXabarchi(config.WEBHOOK_URL, {
  log: () => joriyLog(log),
  httpRuxsat: config.NODE_ENV !== "production",
})

11.3 Testlar

test/ssrf.test.ts — 4 test, tashqi internetga so'rovsiz. Eng qizig'i — rebinding (telefon uchun bo'lingan):

ts
test("DNS rebinding: ulanish birinchi TEKSHIRILGAN IP'ga; " +
  "redirect ichkariga — rad; tashqi redirect — kuzatiladi",
async () => {
  let n = 0;
  const dns = () => Promise.resolve([{
    address: n++ === 0 ? "127.0.0.1" : "169.254.169.254",
    family: 4,
  }]);
  const s = {
    httpRuxsat: true, portlar: [PORT], dns,
    taqiqmi: (ip: string) =>
      ip !== "127.0.0.1" && ichkiManzilmi(ip),
  };
  const j = await xavfsizPost(
    `http://hook.test:${String(PORT)}/v`, "{}", s);
  assert.deepEqual(j, { status: 204, redirectlar: 0 });
  assert.equal(n, 1, "ikkinchi DNS so'rovi bo'lmadi");
  // … 307 → 169.254.169.254 — rad; 308 → /oxirgi — kuzatildi
});

Testdagi taqlid DNS birinchi chaqiruvda 127.0.0.1 (lokal sinov serveri, testda ruxsat), ikkinchisida metadata manzilini qaytaradi. n 1 da qoldi — ikkinchi DNS so'rovi umuman bo'lmadi. Qolgan uch test: 17 ichki va 6 ommaviy manzil jadvali, 13 ta URL hiylasi, aralash DNS javobi.

Ishga tushiramiz (biz ishga tushirdik, kanon nusxasi va yuqoridagi bo'lingan xavfsiz-url.ts bilan, Node 24.21.0):

bash
node --test test/ssrf.test.ts
text
✔ ichkiManzilmi: loopback, xususiy, link-local (bulut metadata), CGNAT, IPv6 ULA/link-local, mapped; ommaviy — ruxsat (7.7337ms)
✔ URL hiylalari — hammasi rad (WHATWG URL parser IPv4 yozuvlarini kanonik qiladi) (2.3039ms)
✔ DNS: birorta javob ichki bo'lsa — rad (A-yozuvlardan biri 10.0.0.5); javob yo'q — rad (0.4868ms)
✔ DNS rebinding: DNS ikkinchi marta 169.254.169.254 qaytarsa ham — ulanish birinchi TEKSHIRILGAN IP'ga; redirect ichkariga — rad; tashqi redirect — kuzatiladi (21.465ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 314.5579

11.4 Tekshiruv va commit

Butun loyiha tekshiruvi (kanon, 2026-10-28, PostgreSQL 18.6): tip va lint xatosiz, testlar 227 (226 + 1 o'tkazilgan) — oldingi darsdagi 7 ta hujum testi va bugungi 4 ta SSRF testi qo'shildi. Brauzer testlari 10/10, migratsiyalar o'zgarmadi.

Bu holat ikki darsning ishi. Oldingi darsdagi hujum.test.ts ni hali commit qilmagan bo'lsangiz — avval uni alohida commit qiling (xabar o'sha darsda). Keyin SSRF:

bash
M="feat(api): SSRF himoyasi — xavfsizPost (ichki IP ro'yxati,"
M+=" o'z DNS tekshiruvimiz va IP'ga bog'langan ulanish, redirect"
M+=" qayta tekshiruvi); webhook shu orqali"
git add src/umumiy/xavfsiz-url.ts src/umumiy/webhook.ts
git add src/server.ts test/ssrf.test.ts
git commit -m "$M"

12. Real ishda

  • Bulut — SSRF orqali metama'lumot kalitlarini o'g'irlash — eng ko'p uchraydigan senariy. Shuning uchun AWS IMDSv2 ni tavsiya qiladi, Google Cloud esa metama'lumot so'rovlarida maxsus sarlavhani (Metadata-Flavor: Google) talab qiladi.
  • Webhook xizmatlari (Stripe, GitHub, Svix) — mijoz URL'iga so'rov yuboradigan har platforma SSRF himoyasini quradi; ko'pincha alohida chiqish proksisi orqali.
  • Bug bounty dasturlarida SSRF — yuqori baholanadigan topilmalardan biri.
  • Intervyuda: "SSRF nima va qanday himoyalanasiz?", "DNS rebinding nima?", "localhost ni bloklash nega yetmaydi?".

Xulosa

  • SSRF — server foydalanuvchi bergan manzilga o'z nomidan so'rov yuboradi; nishon — 127.0.0.1, ichki tarmoq va 169.254.169.254.
  • Matnda localhost qidirish yetmaydi: new URL dan keyin tekshiring, xost esa DNS orqali ham ichkariga olib borishi mumkin.
  • net.BlockList — IPv4 va IPv6 CIDR ro'yxati; IP bo'lmagan qiymat — rad.
  • DNS javobidagi hamma IP tekshiriladi va ulanish lookup orqali aynan tekshirilgan IP'ga ketadi — DNS rebinding ishlamaydi.
  • Redirect'ni avtomatik kuzatmang: har yangi manzil — to'liq qayta tekshiruv, sonini cheklang.
  • vazifalar-api webhook'i endi xavfsizPost orqali yuboradi; 4 ta test, internetsiz.

Keyingi dars: DoS va resurs tugashidan himoya — kichik so'rov bilan serverni qanday "bo'g'ish" mumkin: ReDoS, zip bomba, login toshqini va kanon o'lchagan 3.3 soniyalik kechikish. U yerda parol hash'i uchun semafor yozamiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
SSRF va tashqi URL'lar bilan ishlash: ichki IP, DNS rebinding va xavfsiz webhook — IlmHamroh