Aplikasi web pada akhirnya selalu menyajikan berkas: halaman HTML, lembar gaya CSS, skrip sisi klien, gambar, hingga data JSON siap unduh. Framework biasanya menyediakan fungsionalitas ini sekali jalan, tetapi di baliknya bekerja logika yang sederhana dan layak kita pahami: terjemahkan jalur URL menjadi nama berkas, pastikan jalur tersebut tidak keluar dari folder yang diizinkan, tentukan Content-Type dari ekstensi, lalu kirim isinya. Pada chapter ini kita rakit server berkas statis dari module http, fs, dan path — sekaligus belajar satu celah keamanan klasik yang wajib ditutup: path traversal.

Dari Jalur URL ke Nama Berkas #

Rencana kita sederhana: seluruh berkas yang boleh disajikan tinggal di folder publik di dalam bundle chapter ini, berisi dua berkas kecil berikut:

html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <title>Daftar Tugas</title>
</head>
<body>
  <h1>Daftar Tugas</h1>
  <p>Halaman ini dilayani oleh static file server buatan kita.</p>
  <ul>
    <li>Belajar protokol HTTP</li>
    <li>Buat server dengan module http</li>
  </ul>
</body>
</html>
json
[
  { "id": 1, "judul": "Belajar protokol HTTP", "selesai": true },
  { "id": 2, "judul": "Buat static file server", "selesai": false }
]

Permintaan GET /index.html seharusnya membaca publik/index.html; permintaan GET / kita petakan ke index.html sebagai halaman depan; dan ekstensi .html menentukan Content-Type: text/html. Daftar lengkap pasangan tipe-ekstensi mengikuti standar MIME yang dirangkum di MDN tentang tipe MIME .

Bahaya Path Traversal #

Masalahnya, jalur URL adalah masukan dari luar, dan tidak semua klien beritikad baik. Bayangkan permintaan GET /../package.json: bila kita asal menggabungkan jalur tersebut dengan folder publik, tanda .. bisa membawa pembacaan ke berkas di luar folder yang kita izinkan. Versi rentan dari kode kita tampak seperti ini:

js Versi rentan — jangan ditiru
const relatif = decodeURIComponent(url.pathname);
const berkas = path.join(AKAR, relatif);   // tanpa pemeriksaan batas
const isi = await readFile(berkas);        // bisa membaca berkas luar publik!

Serangan path traversal menyusun jalur yang setelah digabungkan menembus batas folder — termasuk bentuk terenkodasi seperti %2e%2e (titik-titik) atau %2f (garis miring) yang lolos dari pemeriksaan string sederhana. Pertahanannya adalah tiga langkah berurutan: urai persandian URL lebih dulu dengan decodeURIComponent, gabung dan rapikan dengan path.join dilanjutkan path.normalize, lalu periksa bahwa hasilnya masih berada di dalam folder publik sebelum berkas dibuka. File kecil berikut menguji resep tersebut terhadap empat kandidat jalur:

js
import path from 'node:path';

const AKAR = path.resolve('publik');
const host = '127.0.0.1';

function selesaikan(namaUrl) {
  const url = new URL(namaUrl, `http://${host}`);
  const relatif = decodeURIComponent(url.pathname);
  const berkas = path.normalize(path.join(AKAR, relatif));
  const diDalam = berkas === AKAR || berkas.startsWith(AKAR + path.sep);
  return { berkas, diDalam };
}

const kandidat = [
  '/index.html',
  '/gaya.css',
  '/../package.json',
  '/%2f..%2f..%2fpackage.json',
];

for (const nama of kandidat) {
  const { berkas, diDalam } = selesaikan(nama);
  const status = diDalam ? 'diterima ' : 'DITOLAK  ';
  console.log(nama.padEnd(28), status, path.relative(AKAR, berkas));
}

Output yang Dihasilkan:

text
/index.html                  diterima  index.html
/gaya.css                    diterima  gaya.css
/../package.json             diterima  package.json
/%2f..%2f..%2fpackage.json   DITOLAK   ../../package.json

Mari kita telaah bagian-bagian utamanya:

  1. Baris 3 (path.resolve('publik')): folder akar dihitung menjadi path absolut — bentuk yang dibutuhkan agar perbandingan startsWith berbicara dalam satuan yang sama dengan hasil path.join.
  2. Baris 7-8: class URL sudah menetralkan bentuk /../ biasa dan %2e%2e (terlihat dari kandidat ketiga yang jatuh kembali ke package.json di dalam akar), tetapi bukan %2f — garis miring terenkodasi lolos sebagai karakter biasa. Karena itu langkah 8 mengurai persandian lebih dulu; urutan ini penting, sebab mengurai setelah menggabungkan justru membuka celah.
  3. Baris 9-10: path.join menggabungkan, path.normalize merapikan .. yang tersisa, dan pemeriksaan startsWith(AKAR + path.sep) memastikan hasil akhirnya masih di bawah folder akar. Penambahan path.sep bukan hiasan: tanpa pemisah itu, folder publik-rahasia akan lolos karena namanya juga berawalan publik.
  4. Baris 21-25: empat kandidat dicetak dengan status serta jalur relatif hasil penyelesaian — baris keempat memperlihatkan jalur yang keluar dua tingkat dari akar (../../package.json) dan dengan demikian ditolak.

Server Lengkap #

Dengan resep keamanan di tangan, kita rakit server utuhnya. Method di luar GET dan HEAD ditolak dengan 405, jalur aman dipetakan ke berkas, Content-Type dicari dari ekstensi, dan kegagalan membaca berkas dibalas 404:

js
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';

const AKAR = path.resolve('publik');
const host = '127.0.0.1';

const TIPE = new Map([
  ['.html', 'text/html; charset=utf-8'],
  ['.css', 'text/css; charset=utf-8'],
  ['.js', 'text/javascript; charset=utf-8'],
  ['.json', 'application/json; charset=utf-8'],
  ['.txt', 'text/plain; charset=utf-8'],
  ['.png', 'image/png'],
]);

const server = http.createServer(async (req, res) => {
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('hanya GET yang didukung');
    return;
  }

  const url = new URL(req.url, `http://${host}`);
  let relatif = decodeURIComponent(url.pathname);
  if (relatif === '/') relatif = '/index.html';

  const berkas = path.normalize(path.join(AKAR, relatif));
  const diDalam = berkas === AKAR || berkas.startsWith(AKAR + path.sep);
  if (!diDalam) {
    res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('akses di luar folder publik ditolak');
    return;
  }

  try {
    const isi = await readFile(berkas);
    const tipe = TIPE.get(path.extname(berkas)) ?? 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': tipe });
    res.end(isi);
  } catch {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('berkas tidak ditemukan');
  }
});

server.listen(0, '127.0.0.1', async () => {
  const port = server.address().port;
  const dasar = `http://127.0.0.1:${port}`;

  for (const jalur of ['/', '/tugas.json', '/tidak-ada.txt']) {
    const r = await fetch(dasar + jalur);
    const isi = await r.text();
    console.log(r.status, jalur.padEnd(15), (r.headers.get('content-type') ?? '-')
      .padEnd(41), Buffer.byteLength(isi), 'byte');
  }

  await new Promise((selesai) => {
    http.get(
      { host: '127.0.0.1', port, path: '/%2f..%2f..%2fpackage.json' },
      (res) => {
        console.log(res.statusCode, 'serangan path traversal ditolak');
        res.resume();
        res.on('end', selesai);
      },
    );
  });

  server.close();
});

Output yang Dihasilkan:

text
200 /               text/html; charset=utf-8                  311 byte
200 /tugas.json     application/json; charset=utf-8           138 byte
404 /tidak-ada.txt  text/plain; charset=utf-8                 22 byte
403 serangan path traversal ditolak

Mari kita telaah bagian-bagian utamanya:

  1. Baris 8-15 (const TIPE = new Map([...])): peta ekstensi ke Content-Type. Ekstensi yang tidak dikenal jatuh ke application/octet-stream pada baris 38 — tipe aman yang membuat klien mengunduh alih-alih mengeksekusi isi.
  2. Baris 24-26: jalur diurai dan diterjemahkan; permintaan / dipetakan ke /index.html sehingga pengunjung halaman depan tidak perlu mengetik nama berkas.
  3. Baris 28-34: resep tiga langkah dari section sebelumnya diterapkan apa adanya; jalur yang lolos batas dibalas 403 Forbidden — status yang tepat karena server memahami permintaan itu tetapi menolak melayani.
  4. Baris 36-44: readFile dari node:fs/promises membaca seluruh berkas ke memori lalu res.end(isi) mengirimnya; Buffer hasil pembacaan langsung diterima end tanpa perlu konversi string. Kegagalan seperti berkas yang tidak ada tertangkap catch dan dibalas 404.
  5. Baris 51-56: tiga permintaan fetch mencetak status, jalur, Content-Type, dan ukuran body dalam byte — kolom terakhir memakai Buffer.byteLength, bukan isi.length, agar teks berbahasa Indonesia dihitung sebagai byte UTF-8 yang sesungguhnya dikirim.
  6. Baris 58-67: serangan traversal dikirim lewat http.get mentah dengan jalur terenkodasi %2f..%2f..%2f — bentuk yang sengaja dipilih karena fetch dan class URL menetralkan bentuk-bentuk lainnya sebelum terkirim. Server menjawab 403, persis seperti rancangan kita.

Server kita kini melayani halaman dan data melalui HTTP polos. Satu kata belum terpenuhi dari judul Section ini: huruf S — secure. Koneksi kita sejauh ini terbuka bagi siapa pun yang bisa menyadap jalur jaringan; mengenkripsinya dengan TLS dan sertifikat adalah penutup dari perjalanan kita, dibahas pada Chapter HTTPS dan TLS.