Penyajian berkas statis

Tipe konten, streaming berkas, serta bahaya path traversal.

Daftar isi

Penyajian berkas statis adalah pengiriman isi berkas dari disk kepada klien HTTP sebagai respons permintaan. Tiga hal yang harus dikelola bersamaan adalah penentuan tipe konten agar klien merender dengan benar, streaming berkas agar berkas besar tidak dimuat seluruhnya ke memori, serta pencegahan celah path traversal yang dapat membocorkan berkas di luar direktori publik. Modul http bersama modul fs dan path cukup untuk membangun server berkas statis yang aman, tanpa memerlukan paket tambahan. Setelah membaca topik ini, pembaca dapat menyajikan berkas dengan tipe konten yang benar, memakai fs.createReadStream untuk streaming, serta mencegah upaya keluar dari direktori root memakai path.resolve dan startsWith.

Penentuan tipe konten#

Tipe konten ditentukan dari ekstensi berkas dan diberitahukan kepada klien lewat header Content-Type. Tabel pemetaan ekstensi ke tipe MIME dapat disusun sendiri sebagai objek sederhana, atau diambil dari paket mime bila ingin daftar yang lebih lengkap. Pemetaan yang ditulis tangan cukup untuk ekstensi umum seperti .html, .css, .js, .json, .txt, .png, dan .svg, sedangkan ekstensi yang tidak dikenal dipetakan ke application/octet-stream agar klien mengunduh berkas alih-alih mencoba merendernya.

js Tabel pemetaan ekstensi ke tipe konten
// tidak-dijalankan: fragmen konseptual, variabel extensi tidak didefinisikan di sini
const tipeKonten = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.txt': 'text/plain; charset=utf-8',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
};
const tipe = tipeKonten[extensi] || 'application/octet-stream';

Penambahan charset=utf-8 pada tipe teks menjaga berkas yang memuat karakter non-ASCII tetap ditampilkan dengan benar oleh klien. Untuk berkas biner seperti .png atau .svg yang sudah memiliki penanda format internal, charset tidak ditambahkan karena dapat membingungkan parser klien.

Streaming berkas dengan fs.createReadStream#

Berkas besar tidak boleh dimuat seluruhnya ke memori sebelum dikirim. Fungsi fs.createReadStream(jalur) mengembalikan stream Readable yang membaca berkas potongan demi potongan, dan metode .pipe(res) mengalirkan potongan tersebut langsung ke ServerResponse yang merupakan stream Writable. Mekanisme pipe menangani backpressure secara otomatis, sehingga pembacaan berkas menjeda ketika klien belum siap menerima potongan berikutnya.

js server-statis.js
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');

const root = path.resolve('./publik');
const tipeKonten = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.txt': 'text/plain; charset=utf-8',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
};

fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, 'index.html'), '<h1>halo</h1>\n');
fs.writeFileSync(path.join(root, 'catatan.txt'), 'isi catatan\n');

const server = http.createServer((req, res) => {
  let relatif = req.url.split('?')[0];
  try {
    relatif = decodeURIComponent(relatif);
  } catch {
    res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('url tidak valid');
    return;
  }
  if (relatif.endsWith('/')) relatif += 'index.html';
  const jalurDitangani = path.resolve(root, '.' + relatif);
  if (jalurDitangani !== root && !jalurDitangani.startsWith(root + path.sep)) {
    res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
    res.end('jalur terlarang');
    return;
  }
  fs.stat(jalurDitangani, (err, stat) => {
    if (err || !stat.isFile()) {
      res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('berkas tidak ditemukan');
      return;
    }
    const ext = path.extname(jalurDitangani).toLowerCase();
    const tipe = tipeKonten[ext] || 'application/octet-stream';
    res.writeHead(200, { 'content-type': tipe, 'content-length': stat.size });
    fs.createReadStream(jalurDitangani).pipe(res);
  });
});

function requestRaw(port, pathRaw) {
  return new Promise((resolve) => {
    const req = http.request({ host: '127.0.0.1', port, path: pathRaw, method: 'GET' }, (res) => {
      const potongan = [];
      res.on('data', (c) => potongan.push(c));
      res.on('end', () => resolve({ status: res.statusCode, tipe: res.headers['content-type'], body: Buffer.concat(potongan).toString('utf8') }));
    });
    req.end();
  });
}

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const uji = ['/', '/catatan.txt', '/../server-statis.js', '/%2e%2e/server-statis.js', '/hilang.txt'];
  for (const jalur of uji) {
    const r = await requestRaw(port, jalur);
    console.log(`${jalur} -> ${r.status} tipe=${r.tipe} body=${JSON.stringify(r.body.slice(0, 40))}`);
  }
  server.close();
  fs.rmSync(root, { recursive: true, force: true });
});
text Keluaran
/ -> 200 tipe=text/html; charset=utf-8 body="<h1>halo</h1>\n"
/catatan.txt -> 200 tipe=text/plain; charset=utf-8 body="isi catatan\n"
/../server-statis.js -> 403 tipe=text/plain; charset=utf-8 body="jalur terlarang"
/%2e%2e/server-statis.js -> 403 tipe=text/plain; charset=utf-8 body="jalur terlarang"
/hilang.txt -> 404 tipe=text/plain; charset=utf-8 body="berkas tidak ditemukan"

Pemakaian req.url.split('?')[0] mengambil bagian jalur tanpa string kueri tanpa memakai new URL, karena new URL menormalkan /../ dan /%2e%2e/ menjadi / sehingga upaya traversal menjadi tidak terdeteksi. Setelah jalur ditangani dengan path.resolve(root, '.' + relatif), pemeriksaan startsWith(root + path.sep) memastikan hasil resolusi tetap berada di dalam direktori root. Header content-length diisi dari stat.size agar klien tahu ukuran berkas, dan pipe(res) mengalirkan potongan tanpa memuat seluruh berkas ke memori.

Diagram berikut memvisualkan aliran berkas dari disk melalui createReadStream dan pipe ke ServerResponse, kemudian ke klien sebagai potongan respons.

Berkas di diskcatatan.txtcreateReadStreamReadable streamServerResponseWritable streamKlienbukapipepotongan responsBerkas besar mengalir sebagai potongan tanpa dimuat seluruhnya ke memori
Streaming berkas statis dari disk ke klien melalui pipe

Bahaya path traversal dan pencegahannya#

Path traversal adalah upaya keluar dari direktori publik memakai segmen .. pada jalur yang diberikan klien. Bila server memakai path.join(root, req.url) tanpa pemeriksaan, jalur /../server-statis.js akan diresolusi menjadi jalur di luar root dan berkas sensitif dapat dibocorkan. Klien dapat menyandikan .. sebagai %2e%2e untuk mengelabui filter string sederhana yang hanya mencari literal .., sehingga pencegahan harus dilakukan pada level resolusi jalur, bukan pada level pencarian string.

js demo-traversal.js
const path = require('node:path');
const os = require('node:os');

const root = path.join(os.tmpdir(), 'penyajian-demo-publik');

function rentan(jalurDariKlien) {
  return path.join(root, jalurDariKlien);
}

function aman(jalurDariKlien) {
  const ditangani = path.resolve(root, '.' + decodeURIComponent(jalurDariKlien));
  if (ditangani === root || ditangani.startsWith(root + path.sep)) return ditangani;
  return null;
}

const uji = [
  '/catatan.txt',
  '/../server-statis.js',
  '/%2e%2e/server-statis.js',
  '/sub/../catatan.txt',
];

for (const jalur of uji) {
  console.log('input   :', jalur);
  console.log('  rentan:', rentan(jalur));
  console.log('  aman  :', aman(jalur));
}
text Keluaran
input   : /catatan.txt
  rentan: /tmp/penyajian-demo-publik/catatan.txt
  aman  : /tmp/penyajian-demo-publik/catatan.txt
input   : /../server-statis.js
  rentan: /tmp/server-statis.js
  aman  : null
input   : /%2e%2e/server-statis.js
  rentan: /tmp/penyajian-demo-publik/%2e%2e/server-statis.js
  aman  : null
input   : /sub/../catatan.txt
  rentan: /tmp/penyajian-demo-publik/catatan.txt
  aman  : /tmp/penyajian-demo-publik/catatan.txt

Pembandingan rentan dan aman pada keluaran di atas memperlihatkan dua hal penting. Pertama, path.join(root, '/../server-statis.js') menghasilkan jalur di luar root, yaitu /tmp/server-statis.js, sehingga berkas di luar direktori publik dapat diakses. Kedua, versi aman yang memakai path.resolve(root, '.' + decodeURIComponent(jalur)) dan pemeriksaan startsWith(root + path.sep) mengembalikan null untuk jalur yang keluar dari root, sehingga server dapat menolak permintaan tersebut.

Kasus /sub/../catatan.txt pada pengujian di atas menunjukkan bahwa jalur dengan segmen .. yang akhirnya tetap berada di dalam root tidak ditolak. Ini perilaku yang diinginkan, sebab normalisasi jalur seperti ini wajar dan tidak membahayakan. Pemeriksaan dilakukan terhadap hasil resolusi, bukan terhadap keberadaan segmen .. mentah, sehingga jalur yang valid tetap diterima.

Kesalahan umum#

Memakai path.join(root, req.url) untuk merangkai jalur berkas adalah kesalahan paling berbahaya pada server berkas statis. path.join menormalkan .. sebagai bagian dari jalur tanpa memedulikan apakah hasilnya masih di dalam root, sehingga traversal berhasil. Penggantian dengan path.resolve(root, '.' + relatif) diikuti pemeriksaan startsWith(root + path.sep) menutup celah ini.

Memakai fs.readFile dan menulis seluruh buffer ke res.end bekerja untuk berkas kecil tetapi membengkakkan memori untuk berkas besar. Berkas sebesar 100 MB akan dimuat seluruhnya ke memori sebelum dikirim, dan beberapa permintaan bersamaan dapat menghabiskan memori proses. Pemakaian fs.createReadStream(jalur).pipe(res) mengalirkan berkas sebagai potongan dan memakai backpressure untuk menjaga memori tetap kecil.

Lupa menambah path.sep pada pemeriksaan startsWith(root) membuat direktori saudara yang namanya berawalan sama dengan root lolos pemeriksaan. Penambahan path.sep memastikan hanya direktori di dalam root yang dianggap aman, dan pemeriksaan eksplisit ditangani === root untuk kasus jalur akar mencegah false negative ketika relatif adalah string kosong.

Ringkasan#

  • Tipe konten ditentukan dari ekstensi berkas melalui tabel pemetaan sederhana, dengan application/octet-stream sebagai fallback untuk ekstensi yang tidak dikenal.
  • fs.createReadStream(jalur).pipe(res) mengalirkan berkas sebagai potongan tanpa memuat seluruhnya ke memori, dan pipe menangani backpressure secara otomatis.
  • Pencegahan path traversal dilakukan dengan path.resolve(root, '.' + relatif) dan pemeriksaan startsWith(root + path.sep), bukan dengan mencari literal .. pada string mentah.
  • new URL(req.url) menormalkan /../ dan /%2e%2e/ sehingga traversal tidak terdeteksi, sehingga pemrosesan jalur sebaiknya dilakukan pada req.url mentah dengan split('?')[0] dan decodeURIComponent.

Referensi#

  • HTTP — Node.js — Referensi resmi modul http dengan metode ServerResponse.writeHead dan properti IncomingMessage.url.
  • File system — Node.js — Referensi resmi modul fs, termasuk fs.createReadStream, fs.stat, dan perilaku streaming berkas.
  • Path — Node.js — Referensi resmi modul path dengan path.resolve, path.join, dan properti path.sep.