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:
<!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>[
{ "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:
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:
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:
/index.html diterima index.html
/gaya.css diterima gaya.css
/../package.json diterima package.json
/%2f..%2f..%2fpackage.json DITOLAK ../../package.jsonMari kita telaah bagian-bagian utamanya:
- Baris 3 (
path.resolve('publik')): folder akar dihitung menjadi path absolut — bentuk yang dibutuhkan agar perbandinganstartsWithberbicara dalam satuan yang sama dengan hasilpath.join. - Baris 7-8: class
URLsudah menetralkan bentuk/../biasa dan%2e%2e(terlihat dari kandidat ketiga yang jatuh kembali kepackage.jsondi 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. - Baris 9-10:
path.joinmenggabungkan,path.normalizemerapikan..yang tersisa, dan pemeriksaanstartsWith(AKAR + path.sep)memastikan hasil akhirnya masih di bawah folder akar. Penambahanpath.sepbukan hiasan: tanpa pemisah itu, folderpublik-rahasiaakan lolos karena namanya juga berawalanpublik. - 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:
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:
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 ditolakMari kita telaah bagian-bagian utamanya:
- Baris 8-15 (
const TIPE = new Map([...])): peta ekstensi keContent-Type. Ekstensi yang tidak dikenal jatuh keapplication/octet-streampada baris 38 — tipe aman yang membuat klien mengunduh alih-alih mengeksekusi isi. - Baris 24-26: jalur diurai dan diterjemahkan; permintaan
/dipetakan ke/index.htmlsehingga pengunjung halaman depan tidak perlu mengetik nama berkas. - 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. - Baris 36-44:
readFiledarinode:fs/promisesmembaca seluruh berkas ke memori lalures.end(isi)mengirimnya;Bufferhasil pembacaan langsung diterimaendtanpa perlu konversi string. Kegagalan seperti berkas yang tidak ada tertangkapcatchdan dibalas404. - Baris 51-56: tiga permintaan
fetchmencetak status, jalur,Content-Type, dan ukuran body dalam byte — kolom terakhir memakaiBuffer.byteLength, bukanisi.length, agar teks berbahasa Indonesia dihitung sebagai byte UTF-8 yang sesungguhnya dikirim. - Baris 58-67: serangan traversal dikirim lewat
http.getmentah dengan jalur terenkodasi%2f..%2f..%2f— bentuk yang sengaja dipilih karenafetchdan classURLmenetralkan bentuk-bentuk lainnya sebelum terkirim. Server menjawab403, 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.