IlmHamroh
JavaScript Full-stack/25-qism. HTTP, Express va REST API5/51-dars24 daqiqa
Mundarija (31)

Static fayl server Node.js'da: MIME, ETag va 304, Range va 206, path traversal himoyasi

Qisqacha: Static fayl server — diskdagi faylni (HTML, CSS, rasm, video) o'zgartirmasdan beradigan qism. To'g'ri ishlashi uchun to'rt narsa kerak: to'g'ri MIME turi (Content-Type), faylni stream bilan berish, kesh uchun ETag va 304, video uchun Range va 206. Eng xavflisi — path traversal: /..%2fmaxfiy.env kabi so'rov public/ dan tashqaridagi faylni o'qib yuborishi mumkin. Biz o'lchadik: faqat join() bilan yozilgan server DB_PAROL=... qatorini berib yubordi, resolve() + startsWith() tekshiruvi esa to'rt hujumni ham to'xtatdi.

Bu darsda

  • Fayl kengaytmasidan MIME turini tanlaysiz va nega application/octet-stream zaxira ekanini tushuntirasiz.
  • Faylni pipeline bilan stream qilib berasiz.
  • ETag, Last-Modified, If-None-Match va 304 Not Modified qanday ishlashini ko'rasiz.
  • Range sarlavhasini tahlil qilib, 206 va 416 javob berasiz.
  • Path traversal hujumini o'z ko'zingiz bilan ko'rasiz va undan himoya yozasiz; vazifalar-api ning birinchi holatini commit qilasiz.

Oldin bilishingiz kerak: Request body o'qish va hajm cheklovi, Xavfsiz fayl operatsiyalari, pipeline, finished va stream xatolari, HTTP sarlavhalari, MIME turlari, cookie va kesh.

1. Nega bu kerak?

vazifalar-api hozir JSON qaytaradi: GET /vazifalar, POST /vazifalar va boshqalar. Lekin API yonida deyarli doim "oddiy" fayllar ham bo'ladi: bosh sahifa (index.html), API hujjati, favicon, ba'zan rasm yoki video. Ular bazadan emas, diskdan olinadi va o'zgarmasdan beriladi. Shuning uchun ularni static fayllar (static files) deymiz — "qo'zg'almas", har so'rovda bir xil.

Birinchi qarashda ish oddiy: faylni o'qing va javobga yozing. Lekin to'rtta savol darhol chiqadi:

  1. Brauzer menyu.css ni CSS deb qayerdan biladi? (MIME turi)
  2. 200 MB lik videoni xotiraga to'liq o'qisak nima bo'ladi? (stream)
  3. Fayl o'zgarmagan bo'lsa, uni har safar qayta yuborish shartmi? (ETag va 304)
  4. Foydalanuvchi videoning 40-daqiqasiga sakrasa-chi? (Range va 206)

Beshinchi savol esa eng muhimi: so'rov public/ papkadan tashqaridagi faylni so'rasa-chi? Masalan, parollar yozilgan .env faylini.

O'xshatish: kutubxonachi. U kitobni javondan olib beradi (fayl), muqovasiga janrini yozadi (MIME), "sizda shu nashr bor-ku" desangiz qayta bermaydi (304), "faqat 5-bobni bering" desangiz o'shani nusxalaydi (206). Lekin "javonlar orqasidagi xonadan direktorning seyfini olib bering" desangiz — rad etadi (traversal himoyasi).

flowchart TD
  A["GET /index.html"] --> B{"public/ ichidami?"}
  B -- yo'q --> X["404"]
  B -- ha --> C{"fayl bormi?"}
  C -- yo'q --> X
  C -- ha --> D{"If-None-Match = ETag?"}
  D -- ha --> E["304, tanasiz"]
  D -- yo'q --> F{"Range bormi?"}
  F -- yo'q --> G["200 + butun fayl"]
  F -- yaroqli --> H["206 + bo'lak"]
  F -- yaroqsiz --> I["416"]

Diagrammani yuqoridan pastga o'qing: har romb — bitta tekshiruv. Darsning qolgan qismi aynan shu tartibda boradi, faqat MIME turidan boshlaymiz — u har javobda kerak.

2. MIME turi: brauzerga "bu nima" deyish

2.1 Content-Type qayerdan olinadi

MIME turlari darsida ko'rgansiz: MIME turi (MIME type) — text/html, image/png kabi yorliq, u Content-Type sarlavhasida boradi. Brauzer fayl kengaytmasiga emas, aynan shu yorliqqa qaraydi. Server style.css ni text/plain deb yuborsa, brauzer uni stil sifatida qo'llamaydi.

Node'da MIME jadvali yo'q — uni o'zimiz yozamiz. vazifalar-api dagi jadval kichik: faqat bizga kerak bo'lgan sakkizta kengaytma. Quyidagi blok shu g'oyani ko'rsatadi (brauzerda ham ishlaydi):

js
const MIME = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png",
};

function mimeOl(faylNomi) {
  const nuqta = faylNomi.lastIndexOf(".");
  const ext = nuqta === -1 ? "" : faylNomi.slice(nuqta);
  return MIME[ext.toLowerCase()] ?? "application/octet-stream";
}

const nomlar = ["index.html", "LOGO.PNG", "arxiv.tar.gz", "Makefile"];
for (const nom of nomlar) {
  console.log(nom, "→", mimeOl(nom));
}

Konsolda:

text
index.html → text/html; charset=utf-8
LOGO.PNG → image/png
arxiv.tar.gz → application/octet-stream
Makefile → application/octet-stream

Uch narsaga e'tibor bering:

  • toLowerCase() — LOGO.PNG ham rasm. Windows kengaytma harfini farqlamaydi, foydalanuvchilar esa ikkalasini ham yozadi.
  • charset=utf-8 — matnli turlarda kodlashni aytamiz. Busiz o'zbekcha o' va g' dan keyingi belgilar (masalan — yoki kirill harflari) buzilib chiqishi mumkin.
  • application/octet-stream — "noma'lum baytlar" degani. Brauzer bunday faylni ochmaydi, yuklab olishni taklif qiladi. Noma'lum kengaytmaga bu xavfsiz tanlov: text/html deb taxmin qilsak, yuklangan fayl ichidagi skript brauzerda ishga tushib ketishi mumkin.

Maslahat: Katta loyihalarda jadvalni qo'lda yozmaysiz — mime-types paketi yuzlab kengaytmani biladi. Express ham shuni ishlatadi (Express'ga kirish darsida ko'rasiz). Bizning sakkiz qatorli jadval esa bog'liqliksiz va to'liq nazoratda.

2.2 nosniff haqida bir gap

Ba'zi brauzerlar Content-Type noto'g'ri bo'lsa, faylning boshidagi baytlarga qarab turini "taxmin" qiladi. Bu MIME sniffing (turni hidlash) deyiladi va xavfli: rasm deb yuklangan fayl HTML sifatida ochilishi mumkin. X-Content-Type-Options: nosniff sarlavhasi taxminni taqiqlaydi. Uni mashhur middleware'lar darsida helmet kutubxonasi o'zi qo'yadi.

Tekshirib ko'ring: Server .webp rasmlarni beryapti, lekin jadvalda .webp yo'q. Brauzerda nima bo'ladi?

Javob

mimeOl zaxira turni qaytaradi — application/octet-stream. <img> tegidagi rasm ko'pincha baribir chiqadi (brauzer rasm baytlarini taniydi), lekin faylni manzil satri orqali ochsangiz, brauzer uni yuklab olishni taklif qiladi. To'g'risi — jadvalga ".webp": "image/webp" qo'shish.

3. Faylni stream bilan berish

3.1 Nega readFile emas

readFile butun faylni xotiraga o'qiydi. 2 KB lik index.html uchun bu muammo emas. 300 MB lik video uchun esa har so'rov 300 MB xotira oladi; o'nta foydalanuvchi — 3 GB. Stream darsida buning yechimini ko'rgansiz: fayl bo'laklab o'qiladi va har bo'lak darhol tarmoqqa yoziladi.

res — o'zi yoziladigan stream (createServer darsi). Demak fayl stream'ini to'g'ridan unga ulaymiz:

js
await pipeline(createReadStream(fayl), res);

Nega .pipe() emas, pipeline? pipeline darsida ko'rganingizdek, .pipe() xatoni uzatmaydi: fayl o'qishda xato bo'lsa, javob ochiq qolib ketishi mumkin. pipeline esa ikkala tomonni ham yopadi va xatoni await orqali qaytaradi. Mijoz yuklashni yarmida to'xtatsa ham (res yopilsa), pipeline fayl stream'ini o'zi yopadi — ochiq fayl deskriptori qolmaydi.

3.2 Content-Length — oldindan aytish

Stream bilan yozganda Node fayl hajmini bilmaydi. Content-Length bermasak, u chunked kodlashga o'tadi (HTTP server tomonda darsi). Bu ishlaydi, lekin brauzer yuklash foizini ko'rsata olmaydi. Hajmni stat() bilan oldindan bilamiz va sarlavhaga yozamiz. Pastdagi to'liq misolda shuni ko'rasiz.

4. ETag va 304: o'zgarmagan faylni qayta yubormaslik

4.1 Shartli so'rov qanday ishlaydi

Brauzer index.html ni birinchi marta oladi. Javobda server ETag (entity tag — "versiya yorlig'i") qo'yadi: fayl o'zgarsa o'zgaradigan qisqa satr. Brauzer faylni ham, yorliqni ham keshda saqlaydi.

Keyingi safar brauzer so'raydi: "menda W/"109-1a16bdb0c32" versiya bor, o'zgarganmi?" Buni If-None-Match sarlavhasi bilan aytadi. Bunday so'rov shartli so'rov (conditional request) deyiladi. Versiya bir xil bo'lsa, server 304 Not Modified qaytaradi — tanasiz. Brauzer fayl o'rniga keshdagisini ishlatadi.

sequenceDiagram
  participant B as Brauzer
  participant S as Server
  B->>S: GET /index.html
  S-->>B: 200, ETag: W/"109-1a16bdb0c32", 265 bayt
  B->>S: GET /index.html, If-None-Match: W/"109-1a16bdb0c32"
  S-->>B: 304 Not Modified, 0 bayt

Ikkinchi javobda tana yo'q. Fayl katta bo'lsa — tejash ham katta.

4.2 ETag nimadan yasaladi

Eng aniq yo'l — fayl mazmunining hash'i (crypto darsi). Lekin buning uchun har so'rovda butun faylni o'qish kerak. Static serverlar odatda arzonroq yo'l tanlaydi: hajm + o'zgartirilgan vaqt (mtime). vazifalar-api shunday qiladi:

js
const etag = `W/"${st.size.toString(16)}-${
  Math.floor(st.mtimeMs).toString(16)}"`;

109 — 265 baytning o'n oltilik ko'rinishi, ikkinchi qism — millisekundlardagi vaqt. Boshidagi W/ — kuchsiz (weak) ETag: "mazmuni deyarli bir xil" degani, baytma-bayt kafolat emas. Kuchli va kuchsiz ETag farqi HTTP kesh darsida muhim bo'ladi; static fayl uchun kuchsizi yetadi. Qiziq fakt: Express'ning static middleware'i ham aynan shu formatni ishlatadi — keyingi darslarda W/"109-1a16bdb0c33" ni ko'rasiz.

Last-Modified — xuddi shu g'oyaning eski varianti: sana bilan (Sat, 24 Oct 2026 03:01:20 GMT). Brauzer uni If-Modified-Since bilan qaytaradi. Aniqligi — bir soniya, shuning uchun bir soniyada ikki marta o'zgargan faylni sezmaydi. Ikkalasini birga yuborish odatiy hol.

Cache-Control: public, max-age=0, must-revalidate — "saqlab qo'yishing mumkin, lekin har safar ishlatishdan oldin men bilan tekshir". Shunda fayl yangilansa, brauzer eski versiyani ko'rsatib turmaydi.

4.3 Hammasi bitta misolda

Quyidagi blok kichik server ochadi: bitta fayl, ETag, 304 va oddiy Range. Keyin o'ziga uchta so'rov yuboradi. Blok Node uchun — o'z terminalingizda ishga tushiring:

js
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { mkdir, writeFile, stat } from "node:fs/promises";
import { pipeline } from "node:stream/promises";

await mkdir("public", { recursive: true });
await writeFile("public/menyu.txt", "osh 35000\nmanti 30000\n");

const server = createServer(async (req, res) => {
  const fayl = "public/menyu.txt"; // soddalik uchun — bitta fayl
  const st = await stat(fayl);
  const etag = `W/"${st.size}-${Math.floor(st.mtimeMs)}"`;
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.setHeader("ETag", etag);
  res.setHeader("Accept-Ranges", "bytes");
  if (req.headers["if-none-match"] === etag) {
    return res.writeHead(304).end();
  }
  const m = /^bytes=(\d+)-(\d+)$/.exec(req.headers.range ?? "");
  if (m) {
    const [bosh, oxir] = [Number(m[1]), Number(m[2])];
    res.writeHead(206, {
      "Content-Range": `bytes ${bosh}-${oxir}/${st.size}`,
    });
    const oqim = createReadStream(fayl, { start: bosh, end: oxir });
    return pipeline(oqim, res);
  }
  res.writeHead(200, { "Content-Length": st.size });
  await pipeline(createReadStream(fayl), res);
});
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
const url = `http://127.0.0.1:${server.address().port}/menyu.txt`;

const r1 = await fetch(url);
const etag = r1.headers.get("etag");
console.log(r1.status, JSON.stringify(await r1.text()));

const r2 = await fetch(url, { headers: { "If-None-Match": etag } });
console.log(r2.status, JSON.stringify(await r2.text()));

const r3 = await fetch(url, { headers: { Range: "bytes=0-2" } });
console.log(r3.status, r3.headers.get("content-range"));
console.log(JSON.stringify(await r3.text()));
server.closeAllConnections();
server.close();

Konsolda:

text
200 "osh 35000\nmanti 30000\n"
304 ""
206 bytes 0-2/22
"osh"

Birinchi so'rov — butun fayl (22 bayt). Ikkinchisi ETag'ni qaytardi va bo'sh 304 oldi. Uchinchisi faqat 0–2 baytlarni so'radi va osh so'zini oldi.

Diqqat: createReadStream ning end opsiyasi ichiga oladi: start: 0, end: 2 — uchta bayt (0, 1, 2). HTTP'dagi bytes=0-2 ham xuddi shunday. Shu sababli Content-Length = oxir - bosh + 1. Bitta birlikni unutish — Range kodidagi eng ko'p uchraydigan xato.

Tekshirib ko'ring: Server 304 javobiga ham ETag sarlavhasini qo'ydi. Nega bu foydali?

Javob

304 — "keshdagi nusxang hali yaroqli" degani. Javobdagi sarlavhalar (ETag, Cache-Control) keshdagi yozuvni yangilaydi. Masalan, server Cache-Control ni o'zgartirgan bo'lsa, brauzer buni tanani qayta yuklamasdan bilib oladi.

5. Range va 206: faylning bir bo'lagi

5.1 Video va yuklashni davom ettirish

video tegi bilan sahifadagi videoni o'rtasiga sursangiz, brauzer butun faylni yuklamaydi. U so'raydi: Range: bytes=31457280-, ya'ni "30 MB dan boshlab oxirigacha". Server 206 Partial Content va Content-Range: bytes 31457280-…/… bilan javob beradi. Uzilgan yuklashni davom ettirish (download manager'lar, curl -C -) ham shu mexanizmda ishlaydi.

Server Range'ni qo'llashini Accept-Ranges: bytes sarlavhasi bilan e'lon qiladi. Bu sarlavha bo'lmasa, ba'zi pleyerlar videoni sura olmaydi.

Range uch ko'rinishda keladi:

Sarlavha Ma'nosi 265 baytlik faylda
bytes=0-14 0 dan 14 gacha 0–14, 15 bayt
bytes=100- 100 dan oxirigacha 100–264
bytes=-8 oxirgi 8 bayt 257–264

Agar so'ralgan joy fayldan tashqarida bo'lsa (bytes=99999-), server 416 Range Not Satisfiable qaytaradi va Content-Range: bytes */265 bilan haqiqiy hajmni aytadi. Bu qoidalar — RFC 9110, "Range Requests" bo'limi.

5.2 Tahlil — toza funksiya

Range'ni tahlil qilish — tarmoqsiz, toza mantiq. Uni alohida funksiyaga ajratsak, brauzerda ham sinash mumkin. Bu vazifalar-api dagi mantiqning qisqartirilgan ko'rinishi:

js
// natija: undefined — Range yo'q; null — 416; aks holda oraliq
function rangeOl(sarlavha, hajm) {
  if (sarlavha === undefined) return undefined;
  const m = /^bytes=(\d*)-(\d*)$/.exec(sarlavha);
  if (!m || (m[1] === "" && m[2] === "")) return null;
  let bosh;
  let oxir = hajm - 1;
  if (m[1] === "") {
    bosh = Math.max(0, hajm - Number(m[2])); // bytes=-8
  } else {
    bosh = Number(m[1]);
    if (m[2] !== "") oxir = Math.min(Number(m[2]), hajm - 1);
  }
  if (bosh > oxir || bosh >= hajm) return null;
  return { bosh, oxir };
}

const sarlavhalar = [
  undefined, "bytes=0-14", "bytes=100-", "bytes=-8",
  "bytes=99999-", "bytes=0-1,5-9", "elementlar=0-5",
];
for (const s of sarlavhalar) {
  console.log(String(s).padEnd(16), JSON.stringify(rangeOl(s, 265)));
}

Konsolda:

text
undefined        undefined
bytes=0-14       {"bosh":0,"oxir":14}
bytes=100-       {"bosh":100,"oxir":264}
bytes=-8         {"bosh":257,"oxir":264}
bytes=99999-     null
bytes=0-1,5-9    null
elementlar=0-5   null

bytes=0-1,5-9 — bir nechta oraliq. Standart bunga ruxsat beradi, lekin javob multipart/byteranges formatida murakkab bo'ladi. Ko'p serverlar, jumladan bizniki, faqat bitta oraliqni qo'llaydi. Bu yerda 416 qaytaramiz. Standart bunday holda oddiy 200 bilan butun faylni berishga ham ruxsat beradi. Bizning tanlov qat'iyroq, lekin oddiy.

5.3 Haqiqiy server bilan

vazifalar-api holat 05 dagi index.html 265 bayt. Biz ishga tushirdik (Node 24.21.0, Windows 11, curl 8.19.0). A — server manzili, masalan http://127.0.0.1:3000:

bash
curl -i $A/index.html -H "Range: bytes=-8"
text
HTTP/1.1 206 Partial Content
Content-Type: text/html; charset=utf-8
ETag: W/"109-1a16c9d2430"
Last-Modified: Sat, 24 Oct 2026 06:33:20 GMT
Cache-Control: public, max-age=0, must-revalidate
Accept-Ranges: bytes
Content-Range: bytes 257-264/265
Content-Length: 8
Connection: keep-alive
Keep-Alive: timeout=5

</html>

Oxirgi 8 bayt — </html> va qator oxiri. Endi fayldan tashqaridagi joy:

bash
curl -i $A/index.html -H "Range: bytes=99999-"
text
HTTP/1.1 416 Range Not Satisfiable
Content-Type: text/html; charset=utf-8
ETag: W/"109-1a16c9d2430"
Last-Modified: Sat, 24 Oct 2026 06:33:20 GMT
Cache-Control: public, max-age=0, must-revalidate
Accept-Ranges: bytes
Content-Range: bytes */265
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Kichik kuzatuv: 416 javobida ham Content-Type: text/html qolgan — kod sarlavhalarni Range tekshiruvidan oldin qo'yadi. Tana bo'sh bo'lgani uchun zarari yo'q, lekin toza kodda bu sarlavha 416 da kerak emas. Sizdagi ETag va Last-Modified boshqa chiqadi — ular fayl vaqtiga bog'liq (Date qatorini olib tashladik).

6. Path traversal: eng xavfli xato

6.1 Hujum qanday ko'rinadi

Eng sodda static server shunday yoziladi:

js
// ❌ XAVFLI — tekshiruvsiz
const yol = decodeURIComponent(new URL(req.url, "http://x").pathname);
createReadStream(join(ildiz, yol)).pipe(res);

join("public", "/salom.txt") → public/salom.txt. Hammasi joyida. Lekin .. — "bir papka yuqoriga". join("public", "/../maxfiy.env") → maxfiy.env, ya'ni public/ dan tashqarida. Bu hujum path traversal (yo'l bo'ylab chiqish) deyiladi: URL orqali papkalar daraxtida yuqoriga chiqib, berilmasligi kerak bo'lgan faylni olish.

"new URL() .. ni o'zi tozalaydi-ku" deb o'ylash mumkin. Qisman rost. Keling, brauzerda tekshiramiz:

js
const yollar = [
  "/../maxfiy.env",
  "/%2e%2e/maxfiy.env",
  "/..%2fmaxfiy.env",
  "/..%5cmaxfiy.env",
];
for (const y of yollar) {
  const p = new URL(y, "http://x").pathname;
  console.log(p.padEnd(18), "→", decodeURIComponent(p));
}

Konsolda:

text
/maxfiy.env        → /maxfiy.env
/maxfiy.env        → /maxfiy.env
/..%2fmaxfiy.env   → /../maxfiy.env
/..%5cmaxfiy.env   → /..\maxfiy.env

Birinchi ikkitasini URL tozaladi: .. va kodlangan %2e%2e (nuqtalar) yo'qoldi. Lekin %2f — kodlangan /. URL uni papka ajratuvchisi deb hisoblamaydi va tegmaydi. Keyin biz o'zimiz decodeURIComponent qilamiz va /../maxfiy.env hosil bo'ladi. To'rtinchisi — %5c, ya'ni \. Windows'da bu ham papka ajratuvchisi.

6.2 O'lchov: fayl o'qildi

Kanonda public/salom.txt va papkadan tashqarida maxfiy.env (ichida namuna qator) bilan ikki server sinaldi. curl --path-as-is — curl'ning o'zi .. ni tozalamasligi uchun. Natija (Node 24.21.0, Windows 11). O'ng ustunda DB_PAROL so'zini qidiring — u chiqqan har qator muvaffaqiyatli hujum:

text
=== faqat join() (tekshiruvsiz) ===
curl --path-as-is /salom.txt           → "salom\n"
curl --path-as-is /../maxfiy.env       → "yo'q\n"
curl --path-as-is /%2e%2e/maxfiy.env   → "yo'q\n"
curl --path-as-is /..%2fmaxfiy.env     → "DB_PAROL=namuna-parol-emas\n"
curl --path-as-is /..%5cmaxfiy.env     → "DB_PAROL=namuna-parol-emas\n"
=== resolve + startsWith tekshiruvi ===
curl --path-as-is /salom.txt           → "salom\n"
curl --path-as-is /../maxfiy.env       → "yo'q\n"
curl --path-as-is /%2e%2e/maxfiy.env   → "yo'q\n"
curl --path-as-is /..%2fmaxfiy.env     → "yo'q\n"
curl --path-as-is /..%5cmaxfiy.env     → "yo'q\n"

Tekshiruvsiz server ikki hujumda parol faylini berib yubordi. Birinchi ikkitasida himoyalangandek ko'rindi — lekin bu URL ning xizmati, bizniki emas. Haqiqiy loyihada bu fayl .env, package.json, SSH kalit yoki ma'lumotlar bazasi zaxirasi bo'lishi mumkin. %5c hujumi Windows'ga xos; Linux'da \ oddiy belgi, lekin %2f u yerda ham ishlaydi (Linux'da biz sinamadik).

6.3 Himoya: resolve va startsWith

Qoida: yo'lni to'liq hisoblang, keyin ildiz ichida ekanini tekshiring. resolve() barcha .. larni hisoblab, mutlaq yo'l qaytaradi (path darsi):

js
import { join, resolve, sep } from "node:path";

const ildiz = resolve("public");
function ichidami(yol) {
  const fayl = resolve(join(ildiz, yol));
  return fayl === ildiz || fayl.startsWith(ildiz + sep);
}

const yollar = ["/salom.txt", "/css/asosiy.css", "/../maxfiy.env",
  "/rasm/../../maxfiy.env", "/../public-eski/x.txt"];
for (const y of yollar) console.log(y.padEnd(24), ichidami(y));

Konsolda:

text
/salom.txt               true
/css/asosiy.css          true
/../maxfiy.env           false
/rasm/../../maxfiy.env   false
/../public-eski/x.txt    false

Oxirgi qatorga qarang. Nega startsWith(ildiz) emas, startsWith(ildiz + sep)? Chunki …/public-eski ham …/public bilan boshlanadi. Ajratuvchisiz tekshiruv qo'shni public-eski papkasini "ichida" deb o'tkazib yuborardi. sep — Windows'da \, Linux'da /.

vazifalar-api dagi statikBer yana ikki holatni ushlaydi:

  • Buzuq kodlash — /%E0%A4%A (% dan keyin yetarli belgi yo'q). decodeURIComponent URIError tashlaydi. Ushlamasak — 500 xato; biz false qaytaramiz va 404 bo'ladi.
  • Nol bayt — %00. Ba'zi tizimlar satrni shu yerda kesadi (maxfiy.env%00.png). Node fayl yo'lida nol baytni o'zi rad etadi, lekin oldindan tekshirish — ikkinchi qavat himoya.

Diqqat: ".. so'zini qidirib, bo'lsa rad etaman" — yetarli emas. Kodlangan variantlar (%2e%2e, ..%2f, %252e — ikki marta kodlangan) va OS farqlari bor. Qora ro'yxat doim nimanidir o'tkazib yuboradi. Natijaviy yo'lni tekshirish — oq ro'yxat yondashuvi.

Tekshirib ko'ring: Ildiz C:\app\public. So'rov /..\public2\kalit.pem yo'liga aylandi. startsWith(ildiz) nima qaytaradi va startsWith(ildiz + sep) chi?

Javob

resolve natijasi — C:\app\public2\kalit.pem. startsWith("C:\app\public") — true (satr shunday boshlanadi), ya'ni hujum o'tadi. startsWith("C:\app\public\") — false, hujum to'xtaydi. Ajratuvchi papka nomining chegarasini belgilaydi.

7. Hujumchi nigohi

Static server — skanerlarning birinchi nishoni. Avtomatik botlar har ochiq serverga yuzlab yo'llarni sinaydi: /.env, /.git/config, /../../etc/passwd, /..%2f..%2fwindows/win.ini.

Hujum Natija (himoyasiz) Bizning himoya
/..%2fmaxfiy.env .env o'qiladi resolve + startsWith(ildiz + sep)
/.env (public/ ichida tasodifan qolgan) o'qiladi public/ ga faqat ommaviy fayllar; .gitignore emas, alohida papka
/%E0%A4%A 500 va stack trace decodeURIComponent ni try/catch
Ulkan Range so'rovlari bitta faylni ko'p bo'laklab o'qitish faqat bitta oraliq, aks holda 416
/public/../src/server.ts manba kodi ildizdan chiqish taqiqlangan

Ikkinchi qatorga alohida e'tibor bering. Traversal himoyasi public/ dan chiqishni to'xtatadi. Lekin public/ ichiga o'zingiz .env yoki zaxira faylni tashlab qo'ysangiz, uni hech narsa himoya qilmaydi. Static papka — faqat hamma ko'rishi mumkin bo'lgan fayllar uchun.

8. API qadami: static fayllar va holat 05 (commit)

createServer, qo'lda routing va tana o'qish darslarida vazifalar-api ning Express'siz prototipi tayyor bo'ldi. Bugun unga public/ papka va src/statik.ts qo'shamiz. Holat 05 shu bilan yakunlanadi — commit shu darsda.

public/index.html — kichik bosh sahifa (kanon, 265 bayt; uzun qator ataylab bo'linmagan — ETag va Range misollari aynan shu hajmga tayanadi):

html
<!doctype html>
<html lang="uz">
<head><meta charset="utf-8"><title>vazifalar-api</title></head>
<body>
<h1>vazifalar-api</h1>
<p>REST API: <code>GET /vazifalar</code>, <code>POST /vazifalar</code>, <code>GET|PATCH|DELETE /vazifalar/:id</code>.</p>
</body>
</html>

src/statik.ts — diagrammadagi tartibni bajaradigan statikBer (kanon fayli to'liq; uzun qatorlar telefon uchun bo'lingan, kod ma'nosi aynan):

ts
// statik.ts — public/ papkadan fayl berish: MIME, ETag/304,
// Range/206, traversal himoyasi
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, resolve, sep } from "node:path";
import { pipeline } from "node:stream/promises";
import type { IncomingMessage, ServerResponse } from "node:http";

const MIME: Record<string, string> = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png",
  ".ico": "image/x-icon",
  ".txt": "text/plain; charset=utf-8",
};

/** @returns true — fayl berildi (yoki 304/206/416);
 *  false — bunday fayl yo'q (keyingi ishlovchiga) */
export async function statikBer(
  req: IncomingMessage, res: ServerResponse,
  papka: string, yol: string,
): Promise<boolean> {
  const ildiz = resolve(papka);
  let nomi: string;
  try {
    nomi = decodeURIComponent(yol);
  } catch {
    return false; // %E0%A4%A — noto'g'ri kodlash
  }
  if (nomi.includes("\0")) return false;
  const fayl = resolve(join(ildiz,
    nomi.endsWith("/") ? nomi + "index.html" : nomi));
  // Traversal himoyasi: /../package.json kabi yo'l ildizdan chiqmasin
  if (fayl !== ildiz && !fayl.startsWith(ildiz + sep)) return false;
  const st = await stat(fayl).catch(() => undefined);
  if (!st?.isFile()) return false;

  const etag = `W/"${st.size.toString(16)}-${
    Math.floor(st.mtimeMs).toString(16)}"`;
  res.setHeader("Content-Type",
    MIME[extname(fayl).toLowerCase()] ?? "application/octet-stream");
  res.setHeader("ETag", etag);
  res.setHeader("Last-Modified", st.mtime.toUTCString());
  res.setHeader("Cache-Control",
    "public, max-age=0, must-revalidate");
  res.setHeader("Accept-Ranges", "bytes");
  if (req.headers["if-none-match"] === etag) {
    res.writeHead(304).end();
    return true;
  }

  let bosh = 0;
  let oxir = st.size - 1;
  const range = req.headers.range;
  if (range !== undefined) {
    // Faqat bitta oraliq: bytes=0-99, bytes=100-, bytes=-50
    const m = /^bytes=(\d*)-(\d*)$/.exec(range);
    const a = m?.[1] ?? "";
    const b = m?.[2] ?? "";
    if (m && (a !== "" || b !== "")) {
      if (a === "") {
        bosh = Math.max(0, st.size - Number(b));
      } else {
        bosh = Number(a);
        if (b !== "") oxir = Math.min(Number(b), st.size - 1);
      }
    }
    if (!m || (a === "" && b === "") || bosh > oxir
      || bosh >= st.size) {
      res.writeHead(416,
        { "Content-Range": `bytes */${String(st.size)}` }).end();
      return true;
    }
    res.writeHead(206, {
      "Content-Range":
        `bytes ${String(bosh)}-${String(oxir)}/${String(st.size)}`,
      "Content-Length": oxir - bosh + 1,
    });
  } else {
    res.writeHead(200, { "Content-Length": st.size });
  }
  if (req.method === "HEAD") {
    res.end();
    return true;
  }
  await pipeline(
    createReadStream(fayl, { start: bosh, end: oxir }), res);
  return true;
}

Funksiya boolean qaytaradi: true — javob berildi, false — "bunday fayl yo'q, keyingisiga o'tkaz". / bilan tugagan yo'lga index.html qo'shiladi — shuning uchun GET / bosh sahifani beradi.

src/ilova.ts ga ikki o'zgarish. Fayl boshiga import:

ts
import { statikBer } from "./statik.ts";

Va oxirgi throw dan oldin — API marshrutlari tekshirilgandan keyin — bitta qator:

ts
if ((metod === "GET" || metod === "HEAD")
  && (await statikBer(req, res, publicPapka, url.pathname))) return;
throw new HttpXato(404, `${url.pathname} topilmadi`);

Tartib muhim: agar public/vazifalar degan fayl bo'lsa ham, /vazifalar API'ga boradi. HEAD — GET bilan bir xil sarlavhalar, lekin tanasiz (HTTP metodlari darsi).

Kanondagi jonli curl (holat 05; Date qatori olib tashlangan). Bu HEAD so'rovi — javobda faqat sarlavhalar bo'ladi, ETag va Content-Length ga qarang:

bash
curl -I $A/
text
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
ETag: W/"109-1a16bdb0c32"
Last-Modified: Sat, 24 Oct 2026 03:01:20 GMT
Cache-Control: public, max-age=0, must-revalidate
Accept-Ranges: bytes
Content-Length: 265
Connection: keep-alive
Keep-Alive: timeout=5

curl -I — HEAD so'rovi: hamma sarlavha bor, tana yo'q. Traversal urinishi:

bash
curl -i --path-as-is $A/%2e%2e%2fpackage.json
text
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 43
Connection: keep-alive
Keep-Alive: timeout=5

{"xato":"/%2e%2e%2fpackage.json topilmadi"}

package.json berilmadi — 404. Endi test/api.test.ts ga beshinchi test (kanon; qatorlar bo'lingan):

ts
test("statik: index.html, ETag → 304, Range → 206, "
  + "traversal → 404",
  async () => {
    const r = await fetch(`${asos}/`);
    assert.equal(r.status, 200);
    assert.match(r.headers.get("content-type") ?? "", /text\/html/);
    const etag = r.headers.get("etag") ?? "";
    await r.text();
    assert.equal((await fetch(`${asos}/`,
      { headers: { "If-None-Match": etag } })).status, 304);
    const q = await fetch(`${asos}/index.html`,
      { headers: { Range: "bytes=0-14" } });
    assert.equal(q.status, 206);
    assert.equal(await q.text(), "<!doctype html>");
    assert.equal((await fetch(`${asos}/index.html`,
      { headers: { Range: "bytes=99999-" } })).status, 416);
    // fetch URL'ni normallashtiradi (/../ yo'qoladi) — kodlangan
    // nuqtalar bilan
    assert.equal(
      (await fetch(`${asos}/%2e%2e/package.json`)).status, 404);
    assert.equal(
      (await fetch(`${asos}/..%2fpackage.json`)).status, 404);
  });

Test fetch bilan ham 304 oladi: fetch bu so'rovga o'zi Cache-Control: no-cache qo'shadi, lekin bizning statikBer bu sarlavhaga qaramaydi. Express'ga o'tgach bu farq testni yiqitadi — req darsida ko'rasiz.

Holat 05 tekshiruvi (kanon, Node 24.21.0): npm run tip 0 xato, npm run lint 0, testlar 5/5 (oldingi darsdagi 4 ta + static). Prod bog'liqlik — 0, hammasi node:* modullarida. Sovuq start — 201 ms (node src/server.ts, type stripping). Oddiy yuk (autocannon, 10 ulanish × 5 s, server va yuk beruvchi bir mashinada — bitta o'lchov): GET /vazifalar (100 vazifa, 26 KB) — 2 644 req/s, GET /vazifalar/5 — 25 611 req/s. Bu raqamlarni eslab qoling: Express'ga o'tgach xuddi shu o'lchovni takrorlaymiz.

Commit:

bash
git add .
git commit -m "vazifalar-api v0: node:http — CRUD, qo'lda routing, JSON tana (413/415/400), public/ statik fayllar"

9. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
CSS qo'llanmayapti, konsolda "MIME type ('text/plain')" Noto'g'ri Content-Type MIME jadvali, charset=utf-8
Rasm o'rniga yuklab olish oynasi Noma'lum kengaytma → octet-stream Jadvalga qo'shing
Server xotirasi video bilan to'ladi readFile createReadStream + pipeline
Video surilmaydi Range qo'llanmaydi yoki Accept-Ranges yo'q 206 + Content-Range
Range javobi bir bayt kam/ko'p end ichiga olishini unutish oxir - bosh + 1
URIError: URI malformed → 500 decodeURIComponent ushlanmagan try/catch → 404
.env internetdan o'qiladi join tekshiruvsiz yoki startsWith(ildiz) sepsiz resolve + startsWith(ildiz + sep)

URIError: URI malformed — "URI noto'g'ri tuzilgan". % belgisidan keyin ikkita o'n oltilik raqam kutiladi; %A yoki %E0%A4%A kabi kesik ketma-ketlik shu xatoni beradi.

10. Mashqlar

Yechimlar kurs/mashqlar/25/05-statik/ da.

1-mashq (oson): to'ldiring

  • Fayl o'zgarmagan bo'lsa server qaytaradigan status —
  • Brauzer keshdagi versiyani yuboradigan sarlavha —
  • Faylning bir qismi qaytarilganda status —
  • Range fayldan tashqarida bo'lsa status —
  • Noma'lum kengaytma uchun xavfsiz MIME turi — application/
Yechim
  • 304 Not Modified — tanasiz javob, brauzer keshdagini ishlatadi.
  • If-None-Match — ichida server avval bergan ETag.
  • 206 Partial Content — Content-Range bilan.
  • 416 Range Not Satisfiable — Content-Range: bytes */<hajm> bilan.
  • octet-stream — "noma'lum baytlar", brauzer ochmaydi, yuklab oladi.

2-mashq (o'rta): Content-Range yasash

«Tahlil — toza funksiya» bo'limidagi rangeOl natijasidan javob sarlavhalarini yasaydigan rangeSarlavhalari(oraliq, hajm) funksiyasini yozing. U { status, sarlavhalar } qaytarsin: oraliq undefined → 200 va Content-Length; null → 416 va Content-Range: bytes */<hajm>; aks holda 206, Content-Range va Content-Length. Toza JS — saytdagi «Ishga tushir» bilan sinang. Eslatma: Content-Length = oxir - bosh + 1.

Yechim
js
function rangeSarlavhalari(oraliq, hajm) {
  if (oraliq === undefined) {
    return { status: 200, sarlavhalar: { "Content-Length": hajm } };
  }
  if (oraliq === null) {
    const sarlavhalar = { "Content-Range": `bytes */${hajm}` };
    return { status: 416, sarlavhalar };
  }
  const { bosh, oxir } = oraliq;
  return {
    status: 206,
    sarlavhalar: {
      "Content-Range": `bytes ${bosh}-${oxir}/${hajm}`,
      "Content-Length": oxir - bosh + 1,
    },
  };
}

console.log(JSON.stringify(rangeSarlavhalari(undefined, 265)));
console.log(JSON.stringify(rangeSarlavhalari(null, 265)));
console.log(JSON.stringify(
  rangeSarlavhalari({ bosh: 257, oxir: 264 }, 265)));

Konsolda:

text
{"status":200,"sarlavhalar":{"Content-Length":265}}
{"status":416,"sarlavhalar":{"Content-Range":"bytes */265"}}
{"status":206,"sarlavhalar":{"Content-Range":"bytes 257-264/265","Content-Length":8}}

Uchinchi holat — kanondagi curl ... -H "Range: bytes=-8" javobi bilan bir xil: bytes 257-264/265, 8 bayt.

3-mashq (qiyin): xavfsiz yo'l

xavfsizYol(ildiz, urlYol) funksiyasini yozing (Node, node:path). U urlYol ni dekodlasin, buzuq kodlashda va nol baytda null qaytarsin, / bilan tugasa index.html qo'shsin, ildizdan chiqsa null, aks holda ildizga nisbatan yo'lni qaytarsin (relative bilan, Windows'da ham / bilan). relative(a, b) — a dan b ga nisbiy yo'l (path darsi).

Yechim
js
import { join, relative, resolve, sep } from "node:path";

function xavfsizYol(ildiz, urlYol) {
  const asos = resolve(ildiz);
  let nomi;
  try {
    nomi = decodeURIComponent(urlYol);
  } catch {
    return null; // buzuq kodlash
  }
  if (nomi.includes("\0")) return null;
  if (nomi.endsWith("/")) nomi += "index.html";
  const fayl = resolve(join(asos, nomi));
  if (fayl !== asos && !fayl.startsWith(asos + sep)) return null;
  return relative(asos, fayl).split(sep).join("/");
}

const sinovlar = ["/", "/css/asosiy.css", "/..%2fmaxfiy.env",
  "/%E0%A4%A", "/rasm.png%00.txt", "/a/./b/../c.txt"];
for (const s of sinovlar) {
  console.log(s.padEnd(18), xavfsizYol("public", s));
}

Konsolda:

text
/                  index.html
/css/asosiy.css    css/asosiy.css
/..%2fmaxfiy.env   null
/%E0%A4%A          null
/rasm.png%00.txt   null
/a/./b/../c.txt    a/c.txt

Oxirgi qator: . va .. ildiz ichida qolsa — ruxsat, resolve ularni hisoblab qo'yadi. Biz yo'lni taqiqlamaymiz, faqat natija qayerga tushishini tekshiramiz.

4-mashq: API qadami

vazifalar-api ga public/index.html va src/statik.ts ni qo'shing (MIME jadvali, ETag, Last-Modified, Cache-Control, Accept-Ranges, 304, 206/416, HEAD, traversal himoyasi). ilova.ts da uni API marshrutlaridan keyin chaqiring. Testga qo'shing: / → 200 text/html; ETag bilan → 304; Range: bytes=0-14 → 206 va <!doctype html>; bytes=99999- → 416; /%2e%2e/package.json va /..%2fpackage.json → 404. npm run check yashil bo'lsa — commit («API qadami» bo'limidagi xabar bilan).

Yechim

Namuna — «API qadami» bo'limidagi statikBer va ilova.ts qatorlari; Range qismi — «Tahlil — toza funksiya» bo'limidagi rangeOl mantiqi. Testda fetch URL'dagi /../ ni o'zi tozalaydi, shuning uchun traversal'ni faqat kodlangan nuqta yoki %2f bilan sinash mumkin (/%2e%2e/package.json, /..%2fpackage.json). Kodlanmagan /../ ni sinash uchun node:http ning request i yoki curl --path-as-is kerak.

11. Real ishda

  • Prodakshnda static fayllarni ko'pincha Node bermaydi. Ularni nginx yoki CDN (Cloudflare, CloudFront) beradi — tezroq va Node'ning event loop'ini band qilmaydi. Reverse proxy darsida bu sxemani ko'rasiz.
  • Express'da — bitta qator: app.use(express.static("public")). U ichida aynan shu bilimlarni ishlatadi: MIME, ETag, 304, Range, traversal himoyasi. Express'ga kirish darsida bizning statik.ts o'chiriladi.
  • Fayl yuklash xizmatlari (rasm, PDF, video kurslar) — Range va 206 albatta kerak.
  • Xavfsizlik auditida path traversal (CWE ro'yxatida 22-raqam) — eng ko'p topiladigan zaifliklardan biri. Intervyuda: "static server qanday himoyalanadi?", "ETag va Last-Modified farqi?", "206 qachon qaytadi?".

Xulosa

  • Content-Type — kengaytmadan, kichik harf bilan; matnga charset=utf-8; noma'lum — application/octet-stream.
  • Faylni pipeline(createReadStream(fayl), res) bilan bering; hajmni stat() dan Content-Length ga yozing.
  • ETag (hajm + vaqt) va If-None-Match → 304, tanasiz.
  • Range → 206 va Content-Range; fayldan tashqarida → 416; end ichiga oladi.
  • Traversal: new URL ..%2f ni to'xtatmaydi. Faqat resolve() + startsWith(ildiz + sep) ishonchli; buzuq % kodlashni ushlang.

Keyingi dars: HTTPS, TLS va HTTP/2 — sertifikat, https.createServer, ALPN va nega prodakshnda TLS'ni Node emas, proxy bajaradi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Static fayl server Node.js'da: MIME, ETag va 304, Range va 206, path traversal himoyasi — IlmHamroh