SSRF (Server-Side Request Forgery) terjadi kalau penyerang bisa menyuruh server-mu mengirim request ke alamat pilihan penyerang. Bayangin resepsionis kantor yang mau mengambilkan paket dari alamat mana pun yang kamu tulis di kertas. Kamu menulis "ruang arsip direksi, lantai 5". Kamu sendiri gak boleh masuk ke sana, tapi resepsionis itu orang dalam, jadi pintunya terbuka untuknya, dan dia membawakan isinya ke kamu.
🧪 Tebakan cepat: server-mu di AWS EC2 mengambil gambar dari URL kiriman user untuk membuat preview. Penyerang mengirim
http://169.254.169.254/latest/meta-data/iam/security-credentials/. Apa yang didapat penyerang? Tebak dulu, jawabannya ada di bagian "Kenapa server jadi senjata".
Kenapa server jadi senjata
Fitur yang mengambil URL dari user ada di mana-mana: preview link, avatar dari URL, webhook. Versi rentannya seperti ini:
app.post('/fetch-preview', async (req, res) => {
const response = await fetch(req.body.url) // RENTAN: URL dari user, langsung di-fetch
res.send(Buffer.from(await response.arrayBuffer()))
})
Server biasanya berada di jaringan internal yang gak bisa dijangkau dari internet. Lewat SSRF, penyerang meminjam posisi itu:
- Metadata cloud di
169.254.169.254: alamat khusus di dalam mesin AWS, GCP, atau Azure yang memberi tahu mesin itu tentang dirinya sendiri, termasuk kredensial IAM sementara (kunci akses ke layanan cloud). - Layanan internal di
localhostatau IP private: Redis, Elasticsearch, panel admin, yang sering gak pakai password karena dianggap "cuma bisa diakses dari dalam". - Skema selain http:
file:///etc/passwdmembaca file lokal kalau library HTTP-nya mendukungfile://(cURL di PHP mendukungnya;fetchbawaan Node menolaknya).
Jadi di EC2 dengan IMDSv1, request tadi mengembalikan nama role IAM-nya, dan satu request lagi ke .../security-credentials/<nama-role> mengembalikan kredensial AWS sementara: AccessKeyId, SecretAccessKey, dan Token. Itu jawaban tebakannya. Ini persis yang terjadi pada kebocoran Capital One tahun 2019: SSRF lewat WAF yang salah konfigurasi, kredensial dari metadata, lalu data sekitar 106 juta orang diunduh dari S3.
Membaca URL dengan new URL
Jangan memeriksa URL dengan mencari teks. Biarkan new URL() memecahnya, persis seperti nanti fetch membacanya:
| URL | protocol |
hostname |
|---|---|---|
https://images.cdn.com/pic.jpg |
https: |
images.cdn.com |
http://images.cdn.com/pic.jpg |
http: |
images.cdn.com |
https://evil.com/x |
https: |
evil.com |
file:///etc/passwd |
file: |
(kosong) |
https://IMAGES.CDN.COM/x |
https: |
images.cdn.com |
bukan-url |
melempar TypeError: Invalid URL |
protocolselalu diakhiri titik dua:'https:', bukan'https'.- Untuk
httpdanhttps,hostnamesudah diubah ke huruf kecil oleh parser. - Teks yang bukan URL lengkap membuat
new URLmelempar error, jadi bungkus dengantry...catch.
Allowlist: cara paling efektif
function isUrlSafeForFetch(url, allowlist) {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'https:') return false // tolak http:, file:, gopher:, dll
const host = parsed.hostname.toLowerCase()
return allowlist.some((h) => h.toLowerCase() === host)
} catch {
return false // bukan URL yang valid
}
}
const ok = ['images.cdn.com', 'storage.googleapis.com']
app.post('/fetch-preview', async (req, res) => {
if (!isUrlSafeForFetch(req.body.url, ok)) return res.status(400).send('URL not allowed')
const response = await fetch(req.body.url, { redirect: 'error' })
res.send(Buffer.from(await response.arrayBuffer()))
})
url |
Langkah yang menghentikan | Hasil |
|---|---|---|
https://images.cdn.com/pic.jpg |
lolos semua | true |
http://images.cdn.com/pic.jpg |
protocol http: |
false |
https://evil.com/x |
evil.com gak ada di allowlist |
false |
file:///etc/passwd |
protocol file: |
false |
bukan-url |
new URL melempar error, masuk catch |
false |
https://IMAGES.CDN.COM/x |
lolos semua, host-nya images.cdn.com |
true |
- Allowlist berarti daftar host yang boleh. Semua yang gak ada di daftar ditolak, termasuk alamat internal yang gak kepikiran.
- Bandingkan hostname dengan
===, bukanincludesatauendsWith. Hostname gak membedakan huruf besar dan kecil, jadi samakan dulu dengantoLowerCase(). catchtanpa variabel (catch {) boleh di JavaScript modern, dan di sini cukup mengembalikanfalse.
Lapisan tambahan
redirect: 'error': host yang lolos allowlist bisa membalas302kehttp://169.254.169.254/.... Dengan opsi ini,fetchgagal alih-alih mengikuti redirect.- Blokir IP private kalau allowlist gak mungkin dibuat (misalnya webhook ke URL pelanggan): resolve hostname-nya ke IP, lalu tolak
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, dan169.254.0.0/16. Pakai IP hasil resolve itu untuk request-nya, supaya hostname gak bisa berganti IP di antara pengecekan dan request (DNS rebinding). - Firewall keluar (egress): di level jaringan, server aplikasi cuma boleh menghubungi tujuan yang memang dibutuhkan.
IMDSv1 vs IMDSv2
IMDS adalah layanan metadata di EC2. IMDSv1 menjawab satu GET biasa, jadi satu SSRF sudah cukup. IMDSv2 mewajibkan session token: dapatkan dulu lewat PUT, lalu kirim di header setiap GET:
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/
Kebanyakan SSRF cuma bisa mengirim GET tanpa header tambahan, jadi gak bisa mendapat token. GET tanpa token ke instance yang mewajibkan IMDSv2 dijawab 401 Unauthorized. Wajibkan dengan aws ec2 modify-instance-metadata-options --instance-id i-xxx --http-tokens required.
⚠️ Jebakan yang sering ketemu
- Mencocokkan teks, bukan hasil parse.
url.includes('images.cdn.com')ataustartsWith('https://images')bisa ditipu dengan URL yang bentuknya mirip. Parse dengannew URL, lalu bandingkanhostnamedengan===. - Lupa mengecek protocol. Host-nya benar tapi
http:ataufile:tetap harus ditolak. - Lupa huruf besar.
IMAGES.CDN.COMadalah host yang sama. Samakan dengantoLowerCase(). - Mengikuti redirect. Pengecekan di awal gak ada artinya kalau
fetchlalu dibelokkan ke alamat internal.
TL;DR: SSRF berarti penyerang menyuruh server-mu me-request alamat pilihannya, termasuk metadata cloud 169.254.169.254 (di EC2 dengan IMDSv1, hasilnya kredensial AWS sementara), layanan internal, dan file://. Cegah dengan allowlist: parse dengan new URL di dalam try...catch (gagal parse berarti false), wajibkan protocol === 'https:', dan cocokkan hostname huruf kecil dengan ===. Tambahkan redirect: 'error', blokir IP private setelah resolve DNS kalau allowlist gak mungkin, batasi egress, dan wajibkan IMDSv2, yang butuh session token dari PUT sehingga SSRF biasa yang cuma bisa GET gak bisa membaca metadata.