IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime2/69-dars24 daqiqa
Mundarija (28)

Fayl yuklash xavfsizligi: sehrli baytlar, nom va SVG xavfi

Qisqacha: Yuklangan faylning kengaytmasi ham, Content-Type i ham — mijoz yozgan matn, ikkalasi ham yolg'on bo'lishi mumkin. Haqiqiy turni faylning birinchi baytlaridan (sehrli baytlar, file-type kutubxonasi) aniqlang va faqat ruxsat ro'yxatidagi turlarni oling. Faylni foydalanuvchi bergan nom bilan emas, tasodifiy kalit bilan va veb-ildizdan tashqarida saqlang. SVG va HTML'ni qabul qilmang: kanon haqiqiy brauzerda SVG ichidagi skript ishlashini ko'rsatdi.

Bu darsda

  • Faylning haqiqiy turini sehrli baytlardan qo'lda va file-type bilan aniqlaysiz.
  • Fayl nomini faqat ko'rsatish uchun tozalaysiz, saqlash uchun esa tasodifiy kalit berasiz.
  • SVG va HTML nega xavfli ekanini haqiqiy brauzer o'lchovi bilan tushuntirasiz.
  • Zip bomba va antivirus (ClamAV) qayerda kerakligini bilasiz.
  • Loyiha uchun xavfsizlik.ts modulini va uning 6 ta testini yozasiz.

Oldin bilishingiz kerak: Fayl yuklash: multipart va Multer, Xavfsiz fayl operatsiyalari (path traversal, zip slip), zlib: siqish (sehrli baytlar, zip bomba), Static fayl server (nosniff).

Oldingi darsda Multer'ning fileFilter i exe faylni image/png deb o'tkazib yubordi. Bugun shu teshikni va yana beshtasini yopamiz.

1. Nega bu kerak?

Foydalanuvchi faylini qabul qilish — begona odamga serveringizga narsa qo'yishga ruxsat berish. Hujumchi bu imkoniyatdan bir nechta yo'l bilan foydalanadi:

Hujum Natijasi
exe'ni rasm.png deb yuborish boshqa foydalanuvchi "rasm"ni yuklab, dasturni ishga tushiradi
../../ li nom fayl kerakli papkadan tashqariga yoziladi
skriptli SVG yoki HTML sizning domeningizda begona JavaScript ishlaydi (XSS)
1 MB lik arxiv, ichida 1 GB server ochayotganda disk yoki xotira tugaydi
zararli dastur (virus) boshqa foydalanuvchilarga tarqaladi

O'xshatish: aeroportdagi bagaj. Yo'lovchi chamadonga "kiyim" deb yozib qo'ygan — bu yorliq. Xavfsizlik xizmati yorliqqa emas, rentgenga qaraydi. Sehrli baytlar — faylning rentgeni.

Bu darsda har hujumni kanon tajribasida ko'ramiz, keyin himoyasini yozamiz va test bilan tekshiramiz.

2. Haqiqiy tur: sehrli baytlar

2.1 Qo'lda tekshirish

zlib: siqish darsida gzip doim 1f 8b bilan boshlanishini ko'rgansiz — bu sehrli baytlar (magic bytes). Har format o'z imzosi bilan boshlanadi. Kengaytmani o'zgartirish oson, birinchi baytlarni esa format buzilmasdan o'zgartirib bo'lmaydi.

js
const IMZOLAR = [
  ["image/png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
  ["image/jpeg", [0xff, 0xd8, 0xff]],
  ["application/pdf", [0x25, 0x50, 0x44, 0x46, 0x2d]], // %PDF-
  ["image/gif", [0x47, 0x49, 0x46, 0x38]], // GIF8
  ["application/zip", [0x50, 0x4b, 0x03, 0x04]], // PK
  ["application/x-msdownload", [0x4d, 0x5a]], // MZ
];

function turniTop(bosh) {
  for (const [mime, imzo] of IMZOLAR) {
    if (imzo.every((bayt, i) => bosh[i] === bayt)) return mime;
  }
  return undefined;
}

const baytlar = (matn) => new TextEncoder().encode(matn);
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0]);

console.log(turniTop(png));
console.log(turniTop(baytlar("MZ bu aslida dastur")));
console.log(turniTop(baytlar("%PDF-1.7 ...")));
console.log(turniTop(baytlar("<svg><script></script></svg>")));

Konsolda:

text
image/png
application/x-msdownload
application/pdf
undefined
  • PNG imzosi 8 bayt: 89 50 4E 47 (.PNG) va qator oxiri belgilari. Kodda o'n oltilik (0x89), png massivida o'nlik (137) — qiymati bir xil.
  • MZ — Windows dasturlarining boshi (1980-yillardagi dasturchi Mark Zbikowski ismining bosh harflari).
  • SVG — matn, uning sehrli baytlari yo'q. Shuning uchun undefined.

2.2 file-type kutubxonasi

Yuzlab formatning imzosini qo'lda yozish — xato manbai. Ba'zi formatlarda imzo boshida emas (WebP: RIFF + 4 bayt hajm + WEBP), ba'zilarini farqlash uchun ichkariroq o'qish kerak (zip, docx, xlsx — hammasi PK bilan boshlanadi). file-type kutubxonasi buni biladi.

bash
npm i file-type@22.2.0
js
import { fileTypeFromBuffer } from "file-type";

console.log(await fileTypeFromBuffer(boshi));
// { ext: 'png', mime: 'image/png' } yoki undefined

file-type 22 — faqat ESM paket (ES Modules Node'da). Kanon beshta "rasm"ni tekshirdi:

text
rasm.png (aslida exe — MZ)       → file-type: {"ext":"exe","mime":"application/x-msdownload"}
rasm.png (aslida zip)            → file-type: {"ext":"zip","mime":"application/zip"}
rasm.gif (GIF + JS polyglot)     → file-type: {"ext":"gif","mime":"image/gif"}
rasm.svg                         → file-type: undefined
sahifa.html                      → file-type: undefined

Ikkinchi va uchinchi qatorga e'tibor bering.

2.3 Sehrli baytlarning chegarasi: polyglot

Uchinchi fayl — polyglot (bir vaqtda ikki formatda to'g'ri fayl). Uning boshi GIF89a, davomi esa JavaScript izohi ichiga yashirilgan kod: GIF89a/*…*/=alert(1)//. file-type uni haqiqiy GIF deb tanidi — boshi rostdan GIF.

Sehrli baytlar faqat boshini tekshiradi. Fayl ichida nima borligini ular aytmaydi. Polyglot'ga qarshi ikki yo'l bor:

  1. Qayta kodlash. Rasmni ochib, piksellardan yangi fayl yasash. Yashirin baytlar yangi faylga o'tmaydi. Buni Rasmlarni qayta ishlash: sharp darsida qilamiz.
  2. Alohida domen. Foydalanuvchi fayllarini asosiy saytdan boshqa domendan berish. Google shunday qiladi: yuklangan fayllar googleusercontent.com dan ochiladi. Skript ishlasa ham, u asosiy saytning cookie'larini ko'rmaydi.

2.4 Ruxsat ro'yxati

Qoida: "qaysi turlar taqiqlangan" emas, "qaysi turlar ruxsat etilgan" ro'yxatini tuting (Qo'lda routing darsidagi oq ro'yxat g'oyasi). Taqiqlar ro'yxati doim nimanidir unutadi. vazifalar ilovasi uchun kanon beshta turni tanladi:

MIME Kengaytma Nega kerak
image/png png skrinshot
image/jpeg jpg telefon fotosi
image/webp webp zamonaviy rasm
application/pdf pdf hujjat, chek
text/plain txt eslatma

SVG va HTML ro'yxatda yo'q — sababi «SVG va HTML: haqiqiy brauzer o'lchovi» bo'limida. Kengaytma ham ro'yxatdan olinadi, mijozning rasm.PNG.exe idan emas.

2.5 Matn fayllari: imzosi yo'q

text/plain ning sehrli baytlari yo'q — file-type undefined qaytaradi. Kanon modul bunday faylni uch shart bilan qabul qiladi:

  1. Mijoz text/plain degan (boshqa noma'lum tur — rad).
  2. Ichida nol bayt (NUL) yo'q va baytlar to'g'ri UTF-8.
  3. Matn < bilan boshlanmaydi (aks holda u HTML yoki SVG bo'lishi mumkin).

To'g'ri UTF-8 ekanini TextDecoder ning fatal: true rejimi tekshiradi (Buffer va binar ma'lumot). Bir nozik joy bor: modul faylning faqat boshini (4 100 bayt) ko'radi. Kesilgan joyda ko'p baytli harf ikkiga bo'linishi mumkin:

js
const UTF8 = (opts) => new TextDecoder("utf-8", opts);
const sinash = (baytlar, stream) => {
  try {
    UTF8({ fatal: true }).decode(baytlar, { stream });
    return "matn";
  } catch (e) {
    return e.name;
  }
};

const enc = new TextEncoder();
const uzun = enc.encode("ў".repeat(2051)); // har harf 2 bayt
const bosh = uzun.subarray(0, 4100);
const chala = uzun.subarray(0, 4101); // oxirgi harfning yarmi

console.log(uzun.length);
console.log(sinash(bosh, false), sinash(chala, false));
console.log(sinash(chala, true));
console.log(sinash(new Uint8Array([0xff, 0xfe, 0x41]), false));

Konsolda:

text
4102
matn TypeError
matn
TypeError
  • 4 100 bayt — butun harflar, xato yo'q.
  • 4 101 bayt — oxirgi ў ning birinchi bayti qoldi. Oddiy rejimda bu TypeError — yaxshi matn rad etilardi.
  • stream: true — "davomi keladi" degani: chala oxir xato emas. Kanon modul aynan shuni qiladi.
  • ff fe 41 — UTF-8 da hech qachon bo'lmaydigan baytlar. Bu haqiqiy xato.

Chrome'da xato xabari Node'dagidan boshqacha, shuning uchun faqat nomini (e.name) chiqardik — ma'nosi bir xil.

Tekshirib ko'ring: Hujumchi HTML faylni text/plain deb yubordi, lekin boshiga 10 ta bo'sh qator qo'ydi. Modul uni ushlaydimi?

Javob

Ha. Modulning tekshiruvi ^?\s*< — boshidagi BOM va istalgancha bo'shliq (bo'sh qatorlar ham \s) dan keyin < bo'lsa, rad. Kanon testida aynan BOM + ikki bo'shliq + <html> sinalgan — belgilash-tili sababi bilan rad etildi.

3. Fayl nomi: faqat ko'rsatish uchun

3.1 Path traversal: Multer sozlamasi

Xavfsiz fayl operatsiyalari darsida ../ bilan papkadan chiqishni ko'rgansiz. Fayl yuklashda nom mijozdan keladi. Kanon "yomon" serverni yozdi: Multer filename sifatida originalname ni ishlatadi, fayllar public/yuklash ga tushadi. Hujumchi nomi ../../chiqdi.txt bo'lgan fayl yubordi — preservePath sozlamasining ikki qiymati bilan:

text
preservePath=false: 200 {"originalname":"chiqdi.txt","path":"<tmp>\\public\\yuklash\\chiqdi.txt"}
preservePath=true: 200 {"originalname":"../../chiqdi.txt","path":"<tmp>\\chiqdi.txt"}
public/ dan tashqarida fayl paydo bo'ldimi: true | public/ ichida: false

Sukutda (false) Multer ichidagi busboy nomdan yo'l qismlarini olib tashladi — fayl joyida. preservePath: true bilan esa fayl public/yuklash dan ikki pog'ona yuqoriga yozildi. Bu sozlama "papkalar tuzilmasini saqlab yuklash" uchun bor. Uni yoqqan dasturchi bilmasdan eshikni ochadi.

Xulosa ikki qavat: sozlamaga tayanmang va mijoz nomi bilan umuman saqlamang.

3.2 Nomni tozalash

Asl nom baribir kerak: foydalanuvchi ro'yxatda chek (1).pdf ni ko'rishi va yuklab olganda xuddi shu nomni olishi kerak. Shuning uchun nom ma'lumot sifatida saqlanadi (bazada, keyin Fayl metama'lumoti darsida) va ekranga chiqishdan oldin tozalanadi.

Nomda nimalar xavfli:

  • yo'l qismlari (/, \) va ..;
  • boshqaruv belgilari (\r\n — HTTP sarlavhasini buzadi);
  • Windows taqiqlagan belgilar (< > : " | ? *) va zaxira nomlar (CON, NUL, COM1);
  • ko'rinmas va yo'nalish belgilari: U+202E (RLO) matnni teskari ko'rsatadi — hisobot + RLO + fdp.exe ekranda hisobotexe.pdf bo'lib ko'rinadi;
  • juda uzun nom.

Kanon funksiyasining JavaScript nusxasi (brauzerda ham ishlaydi):

js
const WINDOWS_ZAXIRA = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
const kp = (a, b) =>
  `${String.fromCodePoint(a)}-${String.fromCodePoint(b)}`;
const YOMON_BELGI = new RegExp(
  String.raw`[\x00-\x1F\x7F<>:"/\\|?*` +
    kp(0x200b, 0x200f) + kp(0x202a, 0x202e) + kp(0x2066, 0x2069) +
    "]",
  "g",
);

function faylNomiTozala(nom, maks = 120) {
  const oxirgi = nom.normalize("NFC").split(/[/\\]/).at(-1) ?? "";
  let toza = oxirgi
    .replace(YOMON_BELGI, "_")
    .replace(/^[.\s]+|[.\s]+$/g, "");
  const nuqta = toza.lastIndexOf(".");
  const asos = nuqta > 0 ? toza.slice(0, nuqta) : toza;
  const kengaytma = nuqta > 0 ? toza.slice(nuqta) : "";
  if (WINDOWS_ZAXIRA.test(asos)) toza = `_${toza}`;
  if (Array.from(toza).length > maks) {
    const k = Array.from(kengaytma).slice(0, 16).join("");
    toza = Array.from(asos)
      .slice(0, maks - Array.from(k).length)
      .join("") + k;
  }
  return toza === "" || toza === "_" ? "fayl" : toza;
}

const rlo = String.fromCodePoint(0x202e);
console.log(faylNomiTozala("../../etc/passwd"));
console.log(faylNomiTozala("..\\..\\Windows\\win.ini"));
console.log(faylNomiTozala("CON.txt"));
console.log(faylNomiTozala(`hisobot${rlo}fdp.exe`));
console.log(faylNomiTozala("a\r\nSet-Cookie: x.txt"));
console.log(faylNomiTozala("...."));
console.log(faylNomiTozala("chek (1).pdf"));
const uzunNom = faylNomiTozala("я".repeat(300) + ".docx");
console.log(Array.from(uzunNom).length);

Konsolda:

text
passwd
win.ini
_CON.txt
hisobot_fdp.exe
a__Set-Cookie_ x.txt
fayl
chek (1).pdf
120

Qadamlar:

  1. normalize("NFC") — bir harfning ikki xil yozilishini bittaga keltiradi (í bitta belgi yoki i + urg'u belgisi; macOS ikkinchisini yuboradi).
  2. split(/[/\\]/).at(-1) — oxirgi / yoki \ dan keyingi qism: yo'l tashlanadi.
  3. Xavfli belgilar _ ga almashadi. Belgilar kod raqami bilan yozilgan — manba kodida ko'rinmas belgi bo'lmasin.
  4. Boshidagi va oxiridagi nuqta va bo'shliqlar olinadi (.... → bo'sh → fayl).
  5. Windows zaxira nomiga _ qo'shiladi.
  6. Uzunlik — 120 ta kod nuqtasi (Array.from), bayt emas; kengaytma saqlanadi.

Tozalangan nom ham diskka yozish uchun emas. U faqat ekranda va yuklab berishda (Content-Disposition, Fayl yuklab berish darsida) ishlatiladi.

3.3 Saqlash kaliti — tasodifiy

Fayl omborda server o'ylab topgan nom bilan yotadi:

js
function saqlashKaliti(egasiId, kengaytma, prefiks = "vazifalar") {
  if (!/^[a-z0-9]{1,8}$/.test(kengaytma)) {
    throw new TypeError(`noto'g'ri kengaytma: ${kengaytma}`);
  }
  return `${prefiks}/${egasiId}/${crypto.randomUUID()}.${kengaytma}`;
}

const a = saqlashKaliti(7, "png");
console.log(/^vazifalar\/7\/[0-9a-f-]{36}\.png$/.test(a));
console.log(a === saqlashKaliti(7, "png"));
try {
  saqlashKaliti(7, "../x");
} catch (e) {
  console.log(e.name, e.message);
}

Konsolda:

text
true
false
TypeError noto'g'ri kengaytma: ../x

Kalit — vazifalar/7/ + UUID + kengaytma. Kalit tasodifiy bo'lgani uchun natijani to'g'ridan chiqarmadik: har ishga tushishda boshqa. Kengaytma faqat ruxsat ro'yxatidan keladi, lekin funksiya baribir tekshiradi — kim chaqirsa ham, kalitga ../ tushmaydi. Bu chuqur himoya (defense in depth): bir qatlam xato qilsa, keyingisi ushlaydi.

vazifalar/7/ — egasining ID si. U kalitni tartibli qiladi, lekin huquqni tekshirmaydi. Faylni kim ko'ra olishi — bazadagi egalik yozuvi bilan hal qilinadi (28-qismda RBAC va resurs egaligi).

Tekshirib ko'ring: Nega kengaytmani originalname dan olish xato, hatto tur sehrli baytlar bilan tekshirilgan bo'lsa ham?

Javob

Haqiqiy PNG faylni rasm.html nomi bilan yuborish mumkin. Fayl ….html bo'lib saqlansa, ba'zi serverlar uni kengaytmaga qarab text/html bilan beradi. Kengaytma tekshirilgan turdan (image/png → png) olinsa, nom va mazmun doim mos.

4. SVG va HTML: haqiqiy brauzer o'lchovi

4.1 Kanon tajribasi

SVG — rasm formati, lekin ichida <script> bo'lishi mumkin. Bu nazariya emasmi? Kanon Express serverida bitta skriptli SVG ni olti xil usulda berdi va haqiqiy Chromium (Playwright 1.62, headless) bilan ochdi. Skript ishlasa, sahifa sarlavhasini o'zgartiradi:

text
/inline.svg        → title = "XSS ISHLADI: 127.0.0.1"
/attachment.svg    → yuklab olindi (rasm.svg) — sahifada ochilmadi
/csp.svg           → title = ""
/img-ichida.html   → title = "sahifa"
/matn-nosniffsiz   → title = "XSS ISHLADI (sniff)"
/matn-nosniff      → title = ""
Qanday berildi Skript
image/svg+xml, to'g'ridan ochildi ishladi
Content-Disposition: attachment ishlamadi (yuklab olindi)
Content-Security-Policy: sandbox ishlamadi
<img src="…svg"> ichida ishlamadi
HTML, Content-Type siz ishladi (brauzer taxmin qildi)
text/plain + nosniff ishlamadi

Birinchi qator — eng xavflisi. Hujumchi "avatar.svg" yuklaydi va havolasini boshqa foydalanuvchiga yuboradi. Havola ochilganda skript sizning domeningizda ishlaydi: sahifadagi ma'lumotni o'qiydi va foydalanuvchi nomidan so'rov yuboradi. Bu saqlangan XSS (stored XSS).

<img> ichida esa SVG faqat rasm sifatida chiziladi — brauzer skriptni o'chiradi. Lekin hujumchi havolani to'g'ridan ochtiradi, <img> orqali emas.

Beshinchi qator — MIME sniffing: Content-Type bo'lmasa, Chromium tanadagi <html> ni ko'rib, HTML deb taxmin qildi.

4.2 Himoya qatlamlari

  1. Qabul qilmang. Kanon ruxsat ro'yxatida SVG va HTML yo'q — eng ishonchli yo'l.
  2. Veb-ildizdan tashqarida saqlang. Yuklangan fayllar express.static papkasiga (Mashhur middleware'lar) tushsa, server ularni kengaytmaga qarab text/html yoki image/svg+xml bilan beradi. Kanon tajribasidagi public/yuklash aynan shu xato. Fayllar omborda, ular faqat sizning marshrutingiz orqali beriladi.
  3. To'g'ri sarlavhalar bilan bering: tekshirilgan Content-Type, X-Content-Type-Options: nosniff, ko'rsatish shart bo'lmasa Content-Disposition: attachment.
  4. Alohida domen va CSP sandbox — SVG baribir kerak bo'lsa (masalan, dizaynerlar uchun ilova). Yoki SVG ni serverda PNG ga aylantiring.
flowchart TD
  F[Yuklangan fayl] --> H{Hajm limiti}
  H -->|oshdi| R1[413]
  H --> S{Sehrli baytlar}
  S -->|ro'yxatda yo'q| R2[415]
  S -->|matn| M{UTF-8 va boshi '<' emas}
  M -->|yo'q| R2
  S -->|png, jpg, webp, pdf| K[Tasodifiy kalit]
  M -->|ha| K
  K --> O[(Private ombor)]
  O --> B[Marshrut: nosniff, attachment]

Diagrammaga qarang: fayl har bosqichda rad etilishi mumkin, va faqat hamma tekshiruvdan o'tgani tasodifiy kalit bilan omborga tushadi. Foydalanuvchiga u faqat marshrut orqali, to'g'ri sarlavhalar bilan qaytadi.

5. Zip bomba

zlib: siqish darsida gzip bombasini ko'rgansiz. Zip arxiv ham xuddi shunday. Kanon archiver 8 bilan arxiv yasadi: ichida to'rtta 256 MB lik nol fayl.

text
arxiv: 1019.7 KB, ichida 4 × 256 MB = 1 GB nol
cheklov 100 MB (e'lon): RAD (e'lon qilingan): 1-yozuvda jami 256.0 MB > 100.0 MB; nisbat 1029:1
faqat oqim cheklovi 100 MB: RAD (haqiqiy oqim): 100.0 MB o'qilganda to'xtatildi
cheklovsiz: hammasi ochildi: 4 yozuv, 1024.0 MB

Bir megabaytdan kichik fayl — bir gigabayt. Siqish nisbati 1029:1. Foydalanuvchi "hisobotlar.zip" ni yuklasa va server uni ochsa (masalan, ichidagi rasmlarni ko'rsatish uchun), 100 ta shunday arxiv 100 GB.

Kanon yauzl kutubxonasi bilan ikki himoyani sinadi:

  1. E'lon qilingan hajm. Zip arxivning mundarijasida har faylning ochilgandagi hajmi yozilgan. Jami limitdan oshsa — arxivni umuman ochmaymiz. Tez, lekin bu raqamni hujumchi yozgan.
  2. Haqiqiy oqim hisoblagichi. Ochilayotgan baytlarni sanaymiz va limitda to'xtatamiz. Hujumchi raqamni yolg'on yozsa ham ishlaydi.

Ikkalasini birga qo'ying. Va ichidagi fayl nomlarini zip slip uchun tekshirishni unutmang. vazifalar ilovasi arxivlarni qabul qilmaydi (application/zip ruxsat ro'yxatida yo'q) — eng oddiy himoya.

Maslahat: archiver 8 faqat ESM. Internetdagi ko'p misollarda archiver("zip") fabrikasi bor — 8-versiyada u yo'q, new ZipArchive() ishlatiladi (kanon topilmasi).

6. Antivirus: ClamAV

Sehrli baytlar "bu PDF" deydi, lekin "bu PDF ichida zararli kod bor" demaydi. Fayllarni boshqa foydalanuvchilar yuklab olsa (hujjat almashish, korporativ tizimlar), ularni antivirus bilan tekshirish talab qilinadi.

ClamAV — ochiq kodli antivirus. Serverda uning xizmati clamd ishlaydi. Ilova unga faylni INSTREAM buyrug'i bilan bo'laklab yuboradi, clamd esa "toza" yoki topilgan virus nomini qaytaradi. Juda katta fayl (StreamMaxLength dan oshsa) rad etiladi.

Diqqat: ClamAV kanonda sinalmagan: uning imzo bazasini internetdan yuklash va alohida xizmat kerak edi. Yuqoridagi tavsif — rasmiy qo'llanma bo'yicha (2026-10-28 da o'qildi). Kodini va natijasini o'ylab topmaymiz.

Amalda: tekshiruv sekin (soniyalar), shuning uchun u navbatda bajariladi (BullMQ asoslari), fayl esa tekshiruv tugaguncha "kutilmoqda" holatida turadi.

7. Hajm va son limitlari

Oldingi darsdagi Multer limits — birinchi chiziq. Yana ikki joyda limit kerak:

  • Foydalanuvchi bo'yicha. Bitta vazifaga 10 tadan ortiq ilova emas, bitta foydalanuvchiga jami N MB. Bu bazada tekshiriladi (Fayl metama'lumoti darsida kanon limit 10 ni tranzaksiya bilan himoyalaydi).
  • Vaqt bo'yicha. Bir daqiqada nechta yuklash — Rate limiting.

Limitsiz har bir himoya baribir yorilib ketadi: 10 000 ta to'g'ri PNG ham diskni to'ldiradi.

8. Hujumchi nigohi

Endi butun darsni hujumchi ko'zi bilan jamlaymiz: har hujum, uni nima to'xtatadi va kanon buni qanday tekshirgan.

Hujum Himoya Kanon tekshiruvi
exe → image/png sehrli baytlar + ruxsat ro'yxati test: ruxsat-yoq
HTML → text/plain UTF-8, < tekshiruvi test: belgilash-tili
../../ nom nom faqat ko'rsatish uchun, kalit UUID test: passwd, win.ini
RLO, \r\n nomda faylNomiTozala test: hisobot_fdp.exe
skriptli SVG ro'yxatda yo'q, nosniff, attachment brauzer: 6 holat
zip bomba arxiv qabul qilinmaydi; e'lon + oqim limiti 1029:1 tajriba
GIF+JS polyglot qayta kodlash, alohida domen file-type → image/gif

Oxirgi qatorga e'tibor bering: polyglot'ni sehrli baytlar ushlamaydi. Kanon modul GIF ni ruxsat ro'yxatiga qo'shmagan, PNG/JPEG/WebP esa sharp bilan qayta kodlanadi (loyihaga ulanganda — navbat orqali). Himoya — bir nechta qatlam, bittasi emas.

9. Ko'p uchraydigan xatolar

Xato Nega xavfli To'g'risi
file.mimetype ga ishonish mijoz yozgan file-type + ruxsat ro'yxati
kengaytmani originalname dan olish rasm.html kengaytma aniqlangan turdan
originalname bilan saqlash to'qnashuv, traversal randomUUID() kalit
preservePath: true fayl papkadan chiqadi sukut false + o'z kalitingiz
yuklashlarni public/ ga qo'yish SVG/HTML skripti ishlaydi ombor + marshrut
javobda nosniff yo'q brauzer turni taxmin qiladi X-Content-Type-Options: nosniff
TextDecoder fatal siz noto'g'ri baytlar � bo'lib o'tadi { fatal: true }
taqiqlar ro'yxati (.exe yo'q) .scr, .hta ni unutasiz ruxsat ro'yxati

10. Mashqlar

Mashqlar kurs/mashqlar/29/02-xavfsizlik/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri

  1. fileFilter da file.mimetype === "image/png" tekshiruvi exe faylni to'xtatadi.
  2. file-type GIF+JS polyglot faylni image/gif deb taniydi.
  3. <img src="avatar.svg"> ichida SVG skripti ishlaydi.
  4. faylNomiTozala dan chiqqan nom bilan faylni diskka yozsa bo'ladi.
  5. Zip arxivning e'lon qilingan hajmiga to'liq ishonsa bo'ladi.
Yechim
  1. Noto'g'ri — mimetype mijoz matni; kanon tajribasida exe o'tdi.
  2. To'g'ri — boshi rostdan GIF. Shuning uchun qayta kodlash yoki alohida domen kerak.
  3. Noto'g'ri — kanon brauzer o'lchovi: <img> ichida ishlamadi. Lekin to'g'ridan ochilganda ishladi.
  4. Noto'g'ri — u faqat ko'rsatish uchun. Saqlash — tasodifiy kalit bilan.
  5. Noto'g'ri — raqamni hujumchi yozadi; haqiqiy oqim hisoblagichi ham kerak.

2-mashq (o'rta): WebP imzosi

WebP fayl RIFF (4 bayt), keyin 4 baytli hajm (istalgan qiymat), keyin WEBP bilan boshlanadi. turniTop ga o'xshash webpmi(bosh) funksiyasini yozing: 0–3 baytlar RIFF va 8–11 baytlar WEBP bo'lsa true. Uch holatda sinang: haqiqiy WebP boshi, RIFF....WAVE (audio fayl) va 6 baytli qisqa massiv. Ishora: satrni baytlarga TextEncoder aylantiradi, kesish — subarray.

Yechim
js
const enc = new TextEncoder();

function mosmi(bosh, joy, matn) {
  const kutilgan = enc.encode(matn);
  const qism = bosh.subarray(joy, joy + kutilgan.length);
  if (qism.length !== kutilgan.length) return false;
  return kutilgan.every((bayt, i) => qism[i] === bayt);
}

function webpmi(bosh) {
  return mosmi(bosh, 0, "RIFF") && mosmi(bosh, 8, "WEBP");
}

const hajm = [0x24, 0x00, 0x00, 0x00];
const webp = new Uint8Array([
  ...enc.encode("RIFF"), ...hajm, ...enc.encode("WEBPVP8 "),
]);
const wav = new Uint8Array([
  ...enc.encode("RIFF"), ...hajm, ...enc.encode("WAVEfmt "),
]);

console.log(webpmi(webp));
console.log(webpmi(wav));
console.log(webpmi(enc.encode("RIFF..")));

Konsolda:

text
true
false
false

qism.length tekshiruvi qisqa massivda muhim: subarray chegaradan chiqsa xato bermaydi, qisqa natija qaytaradi. Usiz every bo'sh massivda true qaytarishi mumkin edi. WAV ham RIFF bilan boshlanadi — shuning uchun faqat birinchi 4 baytga qarash yetmaydi. file-type bu kabi holatlarni yuzlab formatda biladi.

3-mashq (qiyin): turniAniqla ning toza nusxasi

Kanon turniAniqla(bosh, elon) qoidasini file-type siz yozing: ikkilik tur darsdagi turniTop bilan aniqlanadi (IMZOLAR dan faqat PNG, JPEG, PDF, exe, zip). Qaytsin: { ok: true, mime, kengaytma } yoki { ok: false, sabab } (ruxsat-yoq, noma'lum-tur, matn-emas, belgilash-tili). Ruxsat — PNG, JPEG, PDF va matn. Matn qoidalari «Matn fayllari: imzosi yo'q» bo'limidagidek. Olti holatni sinang: PNG, exe (image/png deb), oddiy matn, NUL li matn, BOM + <html>, application/json deb yuborilgan matn.

Yechim
js
const IMZOLAR = [
  ["image/png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
  ["image/jpeg", [0xff, 0xd8, 0xff]],
  ["application/pdf", [0x25, 0x50, 0x44, 0x46, 0x2d]],
  ["application/zip", [0x50, 0x4b, 0x03, 0x04]],
  ["application/x-msdownload", [0x4d, 0x5a]],
];
const RUXSAT = {
  "image/png": "png",
  "image/jpeg": "jpg",
  "application/pdf": "pdf",
};
const BELGILASH = new RegExp(
  `^${String.fromCodePoint(0xfeff)}?` + String.raw`\s*<`,
);

function turniTop(bosh) {
  for (const [mime, imzo] of IMZOLAR) {
    if (imzo.every((bayt, i) => bosh[i] === bayt)) return mime;
  }
  return undefined;
}

function turniAniqla(bosh, elon) {
  const topildi = turniTop(bosh);
  if (topildi) {
    return topildi in RUXSAT
      ? { ok: true, mime: topildi, kengaytma: RUXSAT[topildi] }
      : { ok: false, sabab: "ruxsat-yoq" };
  }
  const tur = elon.split(";")[0].trim().toLowerCase();
  if (tur !== "text/plain") {
    return { ok: false, sabab: "noma'lum-tur" };
  }
  if (bosh.includes(0)) return { ok: false, sabab: "matn-emas" };
  let matn;
  try {
    matn = new TextDecoder("utf-8", { fatal: true }).decode(bosh);
  } catch {
    return { ok: false, sabab: "matn-emas" };
  }
  if (BELGILASH.test(matn)) {
    return { ok: false, sabab: "belgilash-tili" };
  }
  return { ok: true, mime: "text/plain", kengaytma: "txt" };
}

const b = (s) => new TextEncoder().encode(s);
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
const bom = String.fromCodePoint(0xfeff);

console.log(turniAniqla(png, "application/octet-stream"));
console.log(turniAniqla(b("MZ dastur"), "image/png"));
console.log(turniAniqla(b("Oʻzbekiston"), "text/plain"));
console.log(turniAniqla(b("a\u0000b"), "text/plain"));
console.log(turniAniqla(b(`${bom}  <html>`), "text/plain"));
console.log(turniAniqla(b("{}"), "application/json"));

Konsolda:

text
{ ok: true, mime: 'image/png', kengaytma: 'png' }
{ ok: false, sabab: 'ruxsat-yoq' }
{ ok: true, mime: 'text/plain', kengaytma: 'txt' }
{ ok: false, sabab: 'matn-emas' }
{ ok: false, sabab: 'belgilash-tili' }
{ ok: false, sabab: "noma'lum-tur" }

PNG uchun mijoz application/octet-stream dedi — ahamiyati yo'q, baytlar hal qildi. topildi in RUXSAT — obyektda shunday kalit bormi (in va Object.hasOwn). Kanon modulda bu yerda Object.hasOwn — in prototip zanjiridagi toString kabi kalitlarni ham "bor" deydi, hasOwn esa faqat obyektning o'zini tekshiradi. Bu yechimda topildi faqat IMZOLAR dagi qiymat bo'lishi mumkin, shuning uchun farq yo'q.

11. Loyiha qadami: xavfsizlik.ts moduli

Kanon bu darsning bilimini alohida modulga yig'di: modullar/src/fayl/xavfsizlik.ts. U vazifalar-api dan mustaqil — keyingi darsdagi oqimli yuklash uni chaqiradi. Modul file-type 22.2.0 ga tayanadi (TypeScript 6.0.3, Node 24.21.0). Telefon uchun uzun qatorlar va izohlarni bo'ldik, kod ma'nosi kanondagidek:

ts
// xavfsizlik.ts — yuklangan faylga ishonmaslik: turi magic bytes
// bo'yicha, nom faqat ko'rsatish uchun, saqlash kaliti tasodifiy.
// Kengaytma va Content-Type — mijoz yozgan matn, ularga qarab qaror
// qilinmaydi.
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";

/** Ruxsat etilgan turlar: MIME → saqlashdagi kengaytma.
 * SVG va HTML ataylab yo'q (ichida skript bo'lishi mumkin). */
export const RUXSAT_ETILGAN = {
  "image/png": "png",
  "image/jpeg": "jpg",
  "image/webp": "webp",
  "application/pdf": "pdf",
  "text/plain": "txt",
} as const;
export type RuxsatTur = keyof typeof RUXSAT_ETILGAN;

/** Turni aniqlash uchun boshidan shuncha bayt yetadi
 * (file-type ko'p formatni 4 100 baytda taniydi). */
export const BOSH_BAYT = 4100;

export type TurNatija =
  | { ok: true; mime: RuxsatTur; kengaytma: string }
  | {
      ok: false;
      sabab: "noma'lum-tur" | "ruxsat-yoq" | "matn-emas"
        | "belgilash-tili";
      topildi?: string;
    };

const isRuxsat = (m: string): m is RuxsatTur =>
  Object.hasOwn(RUXSAT_ETILGAN, m);

/** Faylning birinchi baytlari bo'yicha turi. `elon` — mijoz aytgan
 * Content-Type (faqat matn fayli uchun ishora). Ikkilik format
 * topilsa — o'shaniki hal qiladi. Topilmasa va mijoz `text/plain`
 * desa — UTF-8 matnmi va `<` bilan boshlanmaydimi — tekshiriladi. */
export async function turniAniqla(
  bosh: Uint8Array,
  elon: string,
): Promise<TurNatija> {
  const topildi = await fileTypeFromBuffer(bosh);
  if (topildi) {
    return isRuxsat(topildi.mime)
      ? {
          ok: true,
          mime: topildi.mime,
          kengaytma: RUXSAT_ETILGAN[topildi.mime],
        }
      : { ok: false, sabab: "ruxsat-yoq", topildi: topildi.mime };
  }
  const tur = elon.split(";")[0]?.trim().toLowerCase();
  if (tur !== "text/plain") {
    return { ok: false, sabab: "noma'lum-tur" };
  }
  if (bosh.includes(0)) return { ok: false, sabab: "matn-emas" };
  // bosh BOSH_BAYT chegarasida kesilgan bo'lsa, oxirgi UTF-8 belgi
  // chala bo'lishi mumkin — `stream: true` uni xato demaydi
  let matn: string;
  try {
    matn = new TextDecoder("utf-8", { fatal: true })
      .decode(bosh, { stream: bosh.length >= BOSH_BAYT });
  } catch {
    return { ok: false, sabab: "matn-emas" };
  }
  if (BELGILASH_BOSHI.test(matn)) {
    return { ok: false, sabab: "belgilash-tili" };
  }
  return { ok: true, mime: "text/plain", kengaytma: "txt" };
}

const WINDOWS_ZAXIRA = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
// Boshqaruv belgilari, yo'l ajratgichlar, Windows taqiqlagan
// belgilar, nol kenglikdagi va bidi belgilari (U+202E RLO).
// Kod nuqtalari raqam bilan — manbada ko'rinmas belgi bo'lmasin.
const kp = (a: number, b: number) =>
  `${String.fromCodePoint(a)}-${String.fromCodePoint(b)}`;
const YOMON_BELGI = new RegExp(
  String.raw`[\x00-\x1F\x7F<>:"/\\|?*` +
    kp(0x200b, 0x200f) + kp(0x202a, 0x202e) + kp(0x2066, 0x2069) +
    "]",
  "g",
);
const BELGILASH_BOSHI = new RegExp(
  `^${String.fromCodePoint(0xfeff)}?` + String.raw`\s*<`,
);

/** Asl nomni FAQAT ko'rsatish va `Content-Disposition` uchun
 * tozalaydi. Diskka/bucket'ga bu nom bilan YOZILMAYDI. */
export function faylNomiTozala(nom: string, maks = 120): string {
  const oxirgi = nom.normalize("NFC").split(/[/\\]/).at(-1) ?? "";
  let toza = oxirgi
    .replace(YOMON_BELGI, "_")
    .replace(/^[.\s]+|[.\s]+$/g, "");
  const nuqta = toza.lastIndexOf(".");
  const asos = nuqta > 0 ? toza.slice(0, nuqta) : toza;
  const kengaytma = nuqta > 0 ? toza.slice(nuqta) : "";
  if (WINDOWS_ZAXIRA.test(asos)) toza = `_${toza}`;
  // kod nuqtalari (emoji klasterlari emas — fayl nomi uchun yetarli)
  const belgilar = Array.from(toza);
  if (belgilar.length > maks) {
    const k = Array.from(kengaytma).slice(0, 16).join("");
    toza = Array.from(asos)
      .slice(0, maks - Array.from(k).length)
      .join("") + k;
  }
  return toza === "" || toza === "_" ? "fayl" : toza;
}

/** Saqlash kaliti — foydalanuvchi bergan nomdan mustaqil:
 * `vazifalar/<egasi>/<uuid>.<kengaytma>`. */
export function saqlashKaliti(
  egasiId: number | string,
  kengaytma: string,
  prefiks = "vazifalar",
): string {
  if (!/^[a-z0-9]{1,8}$/.test(kengaytma)) {
    throw new TypeError(`noto'g'ri kengaytma: ${kengaytma}`);
  }
  return `${prefiks}/${String(egasiId)}/${randomUUID()}.${kengaytma}`;
}

TypeScript'dagi farqlar JS nusxadan: as const bilan ruxsat ro'yxati turga aylanadi (RuxsatTur), isRuxsat — tur qo'riqchisi (type guard), natija — ok maydoni bo'yicha discriminated union.

Testlar — modullar/test/fayl-xavfsizlik.test.ts, oltita. Haqiqiy PNG, JPEG va WebP ni sharp o'zi yasaydi (4×4 piksel), exe — qo'lda yasalgan MZ + PE sarlavhali 512 bayt. Testdan parcha:

ts
test(
  "ruxsat etilmagan ikkilik tur — " +
    "exe ni image/png deb yuborsa ham rad",
  async () => {
    const exe = new Uint8Array(512);
    exe.set(matn("MZ"), 0);
    exe.set([0x50, 0x45, 0, 0], 0x80); // PE sarlavhasi
    new DataView(exe.buffer).setUint32(0x3c, 0x80, true);
    const r = await turniAniqla(exe, "image/png");
    assert.deepEqual(r, {
      ok: false, sabab: "ruxsat-yoq",
      topildi: "application/x-msdownload",
    });
    const gif = await turniAniqla(await rasm("gif"), "image/gif");
    assert.deepEqual(gif, {
      ok: false, sabab: "ruxsat-yoq", topildi: "image/gif",
    });
  });

GIF haqiqiy rasm bo'lsa ham rad — u ruxsat ro'yxatida yo'q (polyglot xavfi). Testlarni biz ishga tushirdik (kanon nusxasi, Node 24.21.0):

bash
node --test test/fayl-xavfsizlik.test.ts
text
✔ magic bytes: haqiqiy PNG/JPEG/WebP/PDF taniladi, e'lon qilingan tur ahamiyatsiz (11.2252ms)
✔ ruxsat etilmagan ikkilik tur — exe ni image/png deb yuborsa ham rad (3.3297ms)
✔ matn fayli: UTF-8 o'zbekcha o'tadi; NUL, noto'g'ri UTF-8, SVG/HTML rad (1.6205ms)
✔ UTF-8 belgi chegarada kesilgan bo'lsa ham matn o'tadi (0.4337ms)
✔ faylNomiTozala: yo'l, boshqaruv, Windows zaxira nomlari, bidi, uzunlik (2.1842ms)
✔ saqlashKaliti: tasodifiy, egasi bo'yicha, kengaytma qat'iy (0.7851ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 207.0538

Millisekundlar har ishga tushishda boshqacha. Butun modullar paketi kanonda npm run check dan o'tgan: tsc 0, eslint 0 (strictTypeChecked), testlar 82/82.

Commit xabari (modul uchun):

bash
git commit -m "feat(fayl): xavfsizlik — sehrli baytlar, nom tozalash, kalit"

vazifalar-api ga ulash 28-qism (auth) tugagach bo'ladi: saqlash kalitidagi egasiId — tizimga kirgan foydalanuvchi.

12. Real ishda

  • OWASP File Upload Cheat Sheet — sohadagi standart tekshiruv ro'yxati: ruxsat ro'yxati, tur tekshiruvi, nom, hajm, saqlash joyi, antivirus. Bu darsdagi hamma narsa unda bor.
  • Katta platformalar foydalanuvchi fayllarini alohida domendan beradi (Google — googleusercontent.com, GitHub — githubusercontent.com).
  • Bug bounty (zaiflik topganga pul to'lanadigan dasturlar) hisobotlarida SVG va fayl yuklash orqali stored XSS — tez-tez uchraydigan topilma.
  • Intervyuda: "Fayl turini qanday tekshirasiz?", "Nega SVG xavfli?", "Fayl nomini qanday saqlaysiz?"

Xulosa

  • Kengaytma va Content-Type — mijoz matni; haqiqiy tur — sehrli baytlardan (file-type), faqat ruxsat ro'yxati.
  • Sehrli baytlar polyglot'ni ushlamaydi — qayta kodlash yoki alohida domen.
  • Matn fayli: UTF-8 (fatal, chegarada stream: true), NUL yo'q, < bilan boshlanmaydi.
  • Nom — faqat ko'rsatish uchun va tozalangan; saqlash — egasi/uuid.kengaytma kaliti bilan.
  • Skriptli SVG to'g'ridan ochilganda haqiqiy brauzerda ishladi; himoya — qabul qilmaslik, nosniff, attachment, alohida domen.
  • Zip bomba 1029:1 — e'lon qilingan hajm va haqiqiy oqim hisoblagichi birga.

Keyingi dars: Katta fayllarni stream bilan yuklash — 200 MB faylni xotiraga yig'masdan omborga oqizamiz, turniAniqla ni oqim boshida chaqiramiz va uch usulni xotira o'lchovi bilan solishtiramiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

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