Kadang aplikasi Node.js perlu memanggil program lain: mengompresi berkas, menjalankan alat baris perintah, atau memanggil ffmpeg untuk memproses video. Di titik itu, aplikasi kita mewarisi seluruh kekuatan shell — dan seluruh bahayanya. Command injection terjadi ketika masukan pengguna ikut dirangkai ke string perintah, lalu shell menafsirkan bagian dari masukan itu sebagai perintah baru. Chapter Child Process di Section Command Line Applications telah memperkenalkan exec, execFile, spawn, dan fork; chapter ini mengambil sudut keamanannya. Kita akan membuktikan serangan dengan berkas bukti yang benar-benar terbuat, lalu menutupnya dengan argumen terpisah dan allow-list.
Shell yang Terlalu Patuh #
Akar masalahnya mirip SQL injection, hanya ganti mesin pengurainya. Function exec() dari node:child_process menjalankan perintah melalui shell — /bin/sh di sistem Unix-like. Shell adalah penerjemah yang patuh: ia membaca karakter khusus seperti ; (pemisah perintah), && (jalankan bila berhasil), | (pipa), dan $(...) (substitusi perintah) setelah perintah kita selesai. Shell tidak tahu mana bagian yang berasal dari kode dan mana yang berasal dari pengguna — semuanya hanyalah satu string yang harus dijalankan. Selama masukan pengguna menjadi bagian string itu, pengguna sedang menulis setengah perintah shell.
Bukti Serangan dalam Satu Berkas #
Demo berikut memakai touch sebagai “bukti” yang tidak berbahaya: bila berkas penanda berhasil dibuat, berarti shell mengeksekusi perintah kedua yang bukan bagian niat kita. Skenarionya adalah aplikasi pelabel berkas sederhana:
import { exec } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
// Skenario: aplikasi "pelabel berkas" menyusun perintah shell
// dengan nama berkas yang berasal dari pengguna.
const namaBerkas = 'catatan.txt; touch bukti-injeksi.txt';
const perintah = `echo Label: ${namaBerkas}`;
exec(perintah, (error, stdout) => {
console.log('perintah yang diterima shell :', perintah);
console.log('output echo :', stdout.trim());
console.log('bukti-injeksi.txt dibuat? :', existsSync('bukti-injeksi.txt'));
// Bersih-bersih agar skrip bisa dijalankan ulang.
rmSync('bukti-injeksi.txt', { force: true });
});Output yang Dihasilkan:
perintah yang diterima shell : echo Label: catatan.txt; touch bukti-injeksi.txt
output echo : Label: catatan.txt
bukti-injeksi.txt dibuat? : trueMari kita baca buktinya:
- Baris 6-7 (perangkai perintah): template literal menggabungkan
echo Label:dengan nama berkas mentah. Pada masukan normal seperticatatan.txt, perintah yang terbentuk memang bersih — sama seperti SQL injection, pola ini terlihat sehat selama tak ada yang mengujinya. - Baris 9 (
exec(perintah, ...)): satu string utuh diserahkan kepada shell. Shell kemudian memecahnya:echo Label: catatan.txtselesai lebih dulu, lalutouch bukti-injeksi.txtberjalan sebagai perintah kedua yang sah. - Baris 11 (output echo):
stdouthanya memuat hasilecho— perintah kedua tidak menghasilkan output, sehingga serangan ini juga senyap: log aplikasi terlihat normal. - Baris 12 (
existsSync): inilah deteksi kita.touchpembuatan berkas adalah padanan tidak berbahaya darirm -rfatau pengunduhan skrip dari jaringan; prinsip eksekusinya sama persis. Penyerang sesungguhnya bisa menggantitouchdengan perintah apa pun yang diizinkan hak akses proses kita. - Baris 15 (
rmSync): berkas bukti dihapus agar skrip bisa dijalankan berulang — pengingat kecil bahwa serangan nyata tidak membersihkan jejaknya.
Solusi: Argumen Terpisah Tanpa Shell #
Function execFile() menyelesaikan masalah ini secara struktural, bukan dengan pelarian string. Program dipanggil langsung sebagai proses baru tanpa perantara shell, dan seluruh argumen dikirim sebagai elemen array:
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
// Versi AMAN: execFile tidak memanggil shell - argumen diteruskan
// apa adanya ke program, karakter khusus tidak pernah ditafsirkan.
const namaBerkas = 'catatan.txt; touch bukti-injeksi.txt';
execFile('echo', ['Label:', namaBerkas], (error, stdout) => {
console.log('output program :', stdout.trim());
console.log('bukti-injeksi.txt dibuat? :', existsSync('bukti-injeksi.txt'));
});Output yang Dihasilkan:
output program : Label: catatan.txt; touch bukti-injeksi.txt
bukti-injeksi.txt dibuat? : falseHasilnya persis seperti yang kita harapkan: string yang sama kini menjadi nilai argv, bukan kode shell — echo mencetaknya apa adanya lengkap dengan titik komanya, dan tidak ada berkas bukti yang terbuat. Dua method ini sebaiknya dipilih dengan refleks berikut:
| Kondisi | Pilihan | Alasan |
|---|---|---|
| Program tetap + argumen dari luar | execFile() / spawn() tanpa opsi shell | Argumen array tidak pernah ditafsirkan shell |
| Perintah yang kita susun sendiri, tanpa masukan luar | exec() boleh | Pipe dan redireksi nyaman untuk skrip internal |
| Butuh aliran output besar | spawn() tanpa shell | Stream, bukan buffer — pola ini dibahas tuntas di Chapter Child Process |
Validasi Nama Berkas dengan Allow-List #
execFile mencegah injeksi, tetapi bukan berarti masukan bebas masuk. Nama berkas yang aneh tetap layak ditolak lebih awal — lapisan pertama dari prinsip defense in depth. Pola allow-list dari Chapter Validasi dan Sanitasi Input diterjemahkan menjadi satu regex:
function namaBerkasValid(nama) {
return /^[A-Za-z0-9._-]+$/.test(nama);
}
console.log(namaBerkasValid('catatan.txt'));
console.log(namaBerkasValid('catatan.txt; touch bukti-injeksi.txt'));
console.log(namaBerkasValid('../.env'));Output yang Dihasilkan:
true
false
falsePola ^[A-Za-z0-9._-]+$ hanya mengizinkan huruf, angka, titik, garis bawah, dan tanda hubung — himpitan yang cukup bagi nama berkas wajar. Titik koma, spasi, dan ../ semuanya ditolak sebelum sempat menyentuh child_process. Dengan dua lapis ini, nama berkas yang lolos validasi tetap aman meski kelak ada bug pada cara memanggil prosesnya.
Dokumentasi lengkap ketiga function tersebut tersedia di exec()
dan execFile()
pada referensi node:child_process. Injeksi yang kita bahas sejauh ini menyerang apa yang dijalankan server. Serangan berikutnya lebih halus: menyerang apa yang dijalankan server atas nama pengguna yang sedang login — tanpa perlu menyusup satu baris kode pun ke aplikasi kita. Menuju Chapter Cross-Site Request Forgery (CSRF).