Lewati ke konten
CodeAuditAgent
Semua artikel

Pertahanan SSRF untuk Webhook, Pratinjau Tautan, dan Pengambil URL

Bagaimana SSRF mengubah webhook, pratinjau tautan, dan importer menjadi jalan ke metadata cloud dan layanan internal, plus pertahanannya dan contoh Node.js.

· 7 menit baca · Lina Source LLC

Setiap fitur yang menerima sebuah URL dari pengguna lalu mengambilnya dari server Anda adalah potensi server-side request forgery (SSRF, CWE-918). Webhook, pratinjau tautan, avatar dari URL, impor RSS dan kalender, renderer PDF, dan tombol "impor dari URL" semuanya berbagi bentuk yang sama: server Anda membuat permintaan ke tujuan yang dipilih orang lain.

Masalahnya terletak pada di mana server Anda berada. Ia bisa menjangkau hal-hal yang tidak bisa dijangkau penyerang: layanan metadata cloud, panel admin internal, database tanpa kata sandi di jaringan privat, dan layanan di localhost. SSRF mengubah server Anda menjadi proksi penyerang ke jaringan tersebut. Ini sangat mudah muncul di tim kecil, karena fitur yang menyebabkannya terlihat tidak berbahaya: sebuah field URL di halaman pengaturan, kartu pratinjau di ruang obrolan, helper yang mengunduh foto profil. Tidak satu pun dari itu terlihat seperti kode keamanan jaringan, sehingga jarang di-review sebagai kode semacam itu.

Apa yang diincar penyerang

  • Endpoint metadata cloud, yang paling terkenal 169.254.169.254 di AWS, GCP, dan Azure, yang bisa mengembalikan detail instance dan, pada sebagian konfigurasi, kredensial sementara untuk role mesin tersebut.
  • Layanan yang terikat ke localhost, seperti antarmuka admin, server debug, dan endpoint metrik yang mengasumsikan hanya ada pemanggil lokal.
  • Layanan internal pada rentang privat (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) yang tidak punya autentikasi karena memang tidak pernah dimaksudkan dijangkau dari luar.
  • Pemindaian port dan penemuan layanan, memakai waktu respons atau pesan error untuk memetakan jaringan internal.
  • Protokol non-HTTP, jika pustaka pengambilnya mendukung skema seperti file:, gopher:, atau ftp:.

Bahkan ketika responsnya tidak dikembalikan ke penyerang, SSRF blind tetap bisa memicu permintaan yang mengubah state terhadap endpoint internal. Webhook sering bersifat blind, dan itu tidak membuatnya aman. Banyak layanan internal menerima permintaan GET sederhana yang mengubah state, seperti pembersihan cache atau aksi admin di balik sebuah URL, justru karena mereka mengasumsikan tidak ada orang luar yang bisa menjangkaunya.

Mengapa cek sederhana gagal

Naluri pertama adalah mem-parsing URL lalu menolak hostname seperti localhost atau string yang diawali 10. atau 192.168. Ini gagal karena beberapa alasan.

  • Hostname diresolusi menjadi IP. Penyerang mendaftarkan domain yang record A-nya menunjuk ke 127.0.0.1 atau 169.254.169.254, dan cek berbasis hostname tidak melihat ada yang salah.
  • Alamat IP punya banyak penulisan: desimal (2130706433), heksadesimal, bentuk singkat seperti 127.1, dan IPv6 yang memetakan IPv4 seperti ::ffff:127.0.0.1. Pencocokan string melewatkannya.
  • DNS rebinding: domain itu diresolusi ke IP publik saat Anda memvalidasinya dan ke IP privat sesaat kemudian ketika klien HTTP menyambung. Memvalidasi dan menyambung adalah dua lookup yang terpisah.
  • Redirect: URL yang sudah Anda validasi mengembalikan 302 ke http://169.254.169.254/, dan klien HTTP mengikutinya tanpa bertanya kepada Anda.

Benang merahnya adalah validasi terjadi pada sesuatu selain alamat yang benar-benar disambungi socket. Perbaikannya adalah memeriksa IP hasil resolusi pada saat koneksi dibuat, untuk setiap koneksi, termasuk setelah redirect.

Pertahanan, dari yang terkuat

Allowlist tujuan bila memungkinkan

Jika fitur itu hanya perlu berbicara dengan sekumpulan host yang sudah dikenal, seperti integrasi dengan segelintir penyedia, masukkan hostname tersebut ke allowlist dan tolak selebihnya. Ini adalah kontrol terkuat dan paling mudah dinalar. Bandingkan hostname hasil parsing secara persis, bukan dengan cek startsWith atau endsWith, yang menerima host mirip seperti api.example.com.attacker.net atau evilexample.com. Semua yang dibahas di bawah ini ditujukan untuk fitur yang memang harus menerima URL publik sembarang.

Batasi skema dan port

Terima hanya https: (dan http: hanya jika terpaksa). Tolak kredensial di dalam URL dan batasi port ke 443 dan 80 kecuali ada kebutuhan yang jelas. Parsing dengan parser URL standar, jangan pernah dengan regex; parser WHATWG juga menormalkan penulisan IP yang ganjil, yang membantu cek berikutnya.

Resolusi dulu, lalu periksa IP saat menyambung

Blokir rentang loopback, privat, link-local, carrier-grade NAT, unspecified, serta unique-local dan link-local IPv6. Yang terpenting, terapkan cek itu di dalam lookup DNS yang dipakai klien HTTP, sehingga alamat yang divalidasi adalah alamat yang disambungi. Itulah yang menutup celah DNS rebinding. Modul http dan https milik Node menerima fungsi lookup kustom persis untuk keperluan ini.

Daftar blokir di bawah ini mencakup rentang yang penting bagi sebagian besar deployment. Tambahkan rentang publik apa pun milik infrastruktur Anda sendiri, karena load balancer atau API internal dengan IP publik tetap bisa memercayai permintaan dari dalam jaringan Anda. Tolak sebuah hostname jika salah satu alamat hasil resolusinya terblokir, bukan hanya yang pertama, karena klien bisa mencobanya dalam urutan apa pun.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // pemetaan dari IPv4
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Memvalidasi setiap alamat hasil resolusi sebelum socket menyambung
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

Satu detail mudah terlewat: ketika URL berisi IP literal, Node menyambung langsung tanpa memanggil lookup sama sekali. Jadi fungsi permintaannya harus memeriksa sendiri IP literal sebelum menyerahkan prosesnya.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https tidak pernah mengikuti redirect; 3xx diperlakukan sebagai kegagalan
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

Jika Anda memakai fetch di Node, yang dibangun di atas undici, ide yang sama berlaku melalui dispatcher kustom: buat Agent undici dengan opsi connect.lookup dan kirimkan sebagai dispatcher. Prinsipnya tidak berubah: cek itu tinggal di tempat koneksi dibuat. Jika lingkungan Anda mengarahkan trafik keluar melalui proksi HTTP, perhatikan bahwa lookup kemudian terjadi di proksi untuk host tujuan, sehingga proksi itu sendiri harus menegakkan aturan yang sama.

Matikan redirect, atau validasi ulang setiap lompatan

Untuk webhook, jangan mengikuti redirect sama sekali; penerima yang melakukan redirect berarti salah konfigurasi, dan gagal dengan lantang memberi tahu pelanggan untuk memperbaiki endpoint mereka. Untuk pratinjau tautan dan importer, di mana redirect adalah hal normal, ikuti secara manual dengan batas kecil dan jalankan setiap lompatan melalui cek skema, port, dan IP yang sama. Dengan fetch, setel redirect: "manual" lalu tangani header Location sendiri.

Batasi apa yang bisa dilakukan permintaan yang berhasil

  • Setel timeout koneksi dan timeout total, serta batasi ukuran respons, agar pengambil itu tidak bisa dipakai untuk menahan koneksi tetap terbuka atau menarik file besar.
  • Jangan mengembalikan respons mentah atau pesan error terperinci kepada pengguna. Untuk pratinjau tautan, kembalikan hanya judul, deskripsi, dan URL gambar yang diekstrak.
  • Buang header autentikasi dan cookie milik Anda sendiri; pengambil itu tidak boleh mengirim kredensial internal ke host yang dipasok pengguna.

Arahkan melalui proksi egress

Penyiapan yang paling tangguh memindahkan kebijakan keluar dari kode aplikasi. Jalankan pengambilan keluar yang dipicu pengguna melalui proksi egress khusus, atau dari worker terisolasi di segmen jaringan yang memang tidak punya rute ke layanan internal maupun endpoint metadata. Dengan begitu, bug di validasi URL tidak menjadi pelanggaran keamanan, karena jaringannya sendiri yang menolak. Proksi forward open-source bisa menegakkan aturan tujuan untuk Anda, dan worker terpisah juga mengisolasi respons yang lambat atau bermusuhan dari proses web utama Anda.

Perkuat layanan metadata

Di AWS, wajibkan IMDSv2, yang membutuhkan token sesi yang diperoleh lewat permintaan PUT, dan pertahankan batas hop di 1 agar kontainer tidak bisa menjangkaunya melalui host. GCP dan Azure mensyaratkan header permintaan tertentu pada pemanggilan metadata. Ini menaikkan standar secara signifikan untuk SSRF sederhana berbasis GET, tetapi keduanya adalah lapisan kedua, bukan pengganti pemblokiran alamat link-local. Berikan pula instance atau service role hanya izin yang dibutuhkan aplikasi, sehingga kredensial yang dicuri lewat layanan metadata membuka sesedikit mungkin.

Menguji pertahanan Anda

  • Coba http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ dan http://169.254.169.254/latest/meta-data/ lalu pastikan masing-masing ditolak.
  • Arahkan sebuah hostname yang Anda kendalikan ke 127.0.0.1 dan pastikan cek lookup menolaknya. Lalu beri ia dua record A, satu publik dan satu privat, dan pastikan ia tetap ditolak.
  • Sajikan redirect 302 ke alamat privat dari sebuah host publik dan pastikan ia tidak diikuti.
  • Coba URL file:, ftp:, dan gopher:, serta URL yang berisi kredensial atau port tidak biasa.

Dalam review kode, cari setiap tempat sebuah URL permintaan dibangun dari input: fetch, axios, got, requests, http.Get, pemanggilan page.goto pada headless browser, dan pustaka pemrosesan gambar yang menerima URL. CodeAuditAgent menelusuri alur data tersebut di repositori publik dan snippet yang ditempel lalu melaporkan SSRF sebagai CWE-918 lengkap dengan lokasi pemanggilan yang dikutip, skenario eksploit, dan sebuah patch.