Fitur penanganan URL eksternal seperti webhook dispatcher, preview tautan, dan importir avatar rentan terhadap serangan Server-Side Request Forgery (SSRF) jika server aplikasi mengambil data tanpa verifikasi ketat. Penyerang dapat mengarahkan request Django ke jaringan privat internal atau metadata instance cloud.

Anatomi Celah SSRF pada Django

SSRF terjadi saat server web mengeksekusi request HTTP ke target yang dikontrol oleh pengguna. Jika aplikasi berjalan di AWS, GCP, atau Azure, target paling kritis adalah endpoint Instance Metadata Service (IMDS) di IP 169.254.169.254. Mengakses endpoint ini dapat membocorkan kredensial IAM role, access token, dan konfigurasi sensitif.

Selain metadata cloud, backend juga rentan mengakses service internal yang tidak terproteksi autentikasi, seperti:

  • Loopback address: 127.0.0.1 atau ::1 (akses ke database local, Redis, atau Django admin internal).
  • RFC 1918 subnets: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (komunikasi service internal microservices).
  • Link-local subnets (RFC 3927): 169.254.0.0/16 dan IPv6 fe80::/10.
  • IPv6 Unique Local (RFC 4193): fc00::/7.

Implementasi URL Validator Menggunakan Modul ipaddress

Validasi berbasis string atau regex pada hostname rentan terhadap bypass (misalnya http://0x7f000001 atau http://0177.0.0.1). Modul standar Python ipaddress menyederhanakan klasifikasi IP dengan akurat.

import ipaddress
import socket
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError

def is_blocked_ip(ip_str: str) -> bool:
    """Periksa apakah IP termasuk loopback, link-local, private, atau reserved."""
    try:
        ip = ipaddress.ip_address(ip_str)
    except ValueError:
        return True

    return (
        ip.is_private
        or ip.is_loopback
        or ip.is_link_local
        or ip.is_reserved
        or ip.is_multicast
        or ip.is_unspecified
    )

def validate_public_url(url: str) -> None:
    """Validasi skema dan pastikan hostname tidak mengarah ke IP terlarang."""
    parsed = urlsplit(url)
    
    if parsed.scheme not in ("http", "https"):
        raise ValidationError("Skema URL hanya boleh http atau https.")
    
    hostname = parsed.hostname
    if not hostname:
        raise ValidationError("Hostname URL tidak valid.")

    port = parsed.port or (443 if parsed.scheme == "https" else 80)

    try:
        addr_info = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
    except socket.gaierror:
        raise ValidationError(f"Resolusi DNS gagal untuk domain: {hostname}")

    if not addr_info:
        raise ValidationError("DNS tidak mengembalikan alamat IP.")

    for item in addr_info:
        ip_addr = item[4][0]
        if is_blocked_ip(ip_addr):
            raise ValidationError(f"Akses ke IP target ({ip_addr}) dilarang.")

Pasang fungsi validator tersebut langsung pada model field Django:

from django.db import models

class WebhookSubscription(models.Model):
    target_url = models.URLField(validators=[validate_public_url])
    created_at = models.DateTimeField(auto_now_add=True)

Celah TOCTOU & Bahaya DNS Rebinding

Validasi pada layer form atau model saja tidak cukup karena adanya celah Time-of-Check to Time-of-Use (TOCTOU) melalui DNS Rebinding.

Skenario DNS Rebinding:

  1. Pengguna mendaftarkan URL https://rebind.attacker.com.
  2. Django memanggil validate_public_url. DNS resolver attacker mengembalikan IP publik valid (misal 93.184.216.34 dengan TTL 0). Validasi form lolos.
  3. Beberapa detik kemudian, worker Django mengeksekusi request HTTP via library standar (seperti requests.get()).
  4. Resolver attacker merespons kueri DNS kedua dengan IP 169.254.169.254. Request diarahkan ke metadata cloud.

Mitigasi DNS Rebinding: Fetcher dengan IP Pinning

Untuk meniadakan TOCTOU, resolusi DNS, validasi IP, dan eksekusi koneksi TCP harus diikat langsung ke IP yang sama (IP pinning) menggunakan modul standar http.client dan ssl.

import http.client
import ssl
from urllib.parse import urlsplit

def safe_fetch(url: str, timeout: int = 5) -> bytes:
    """
    Eksekusi HTTP request dengan IP pinning untuk mencegah DNS Rebinding.
    SNI dan header Host dipertahankan untuk kompatibilitas TLS/virtual host.
    """
    parsed = urlsplit(url)
    if parsed.scheme not in ("http", "https"):
        raise ValueError("Skema tidak didukung.")

    hostname = parsed.hostname
    port = parsed.port or (443 if parsed.scheme == "https" else 80)

    # Resolusi DNS independen
    addr_info = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
    valid_ips = [item[4][0] for item in addr_info if not is_blocked_ip(item[4][0])]

    if not valid_ips:
        raise ValueError("Tidak ada IP publik valid untuk host tersebut.")

    # Pinning ke IP pertama yang terverifikasi aman
    pinned_ip = valid_ips[0]

    headers = {
        "Host": hostname,
        "User-Agent": "Django-SafeFetcher/1.0",
        "Connection": "close",
    }

    path = parsed.path or "/"
    if parsed.query:
        path = f"{path}?{parsed.query}"

    if parsed.scheme == "https":
        context = ssl.create_default_context()
        conn = http.client.HTTPSConnection(
            pinned_ip,
            port=port,
            timeout=timeout,
            context=context,
            server_hostname=hostname,  # SNI dicek terhadap nama domain, bukan pinned IP
        )
    else:
        conn = http.client.HTTPConnection(pinned_ip, port=port, timeout=timeout)

    try:
        conn.request("GET", path, headers=headers)
        response = conn.getresponse()

        # Matikan auto-redirect untuk mencegah redirect ke IP private
        if 300 <= response.status < 400:
            location = response.getheader("Location")
            raise ValueError(f"Redirect dilarang untuk mencegah SSRF bypass: {location}")

        if response.status >= 400:
            raise ValueError(f"HTTP Error: {response.status}")

        return response.read()
    finally:
        conn.close()
Catatan Keamanan: Jangan pernah mengaktifkan penanganan redirect otomatis secara transparan. Jika redirect diizinkan, setiap endpoint redirect (header Location) wajib divalidasi ulang dari awal menggunakan pipeline IP pinning yang sama.

Unit Testing Validator SSRF

Pengujian unit dapat dilakukan dengan memanfaatkan unittest.mock.patch pada modul socket.getaddrinfo tanpa memerlukan koneksi jaringan aktual.

from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from .validators import validate_public_url

class SSRFValidatorTestCase(SimpleTestCase):
    def test_block_loopback_ip(self):
        with self.assertRaises(ValidationError):
            validate_public_url("http://127.0.0.1/admin")

    def test_block_cloud_metadata_ip(self):
        with self.assertRaises(ValidationError):
            validate_public_url("http://169.254.169.254/latest/meta-data/")

    def test_block_rfc1918_ips(self):
        private_urls = [
            "http://10.0.0.1/status",
            "http://172.16.5.10:8080/metrics",
            "http://192.168.1.254/",
        ]
        for url in private_urls:
            with self.subTest(url=url):
                with self.assertRaises(ValidationError):
                    validate_public_url(url)

    @patch("socket.getaddrinfo")
    def test_block_dns_resolving_to_private_ip(self, mock_getaddrinfo):
        # Simulasi domain eksternal yang memetakan ke IP internal
        mock_getaddrinfo.return_value = [
            (2, 1, 6, "", ("10.200.0.5", 80))
        ]
        with self.assertRaises(ValidationError):
            validate_public_url("http://internal-tunnel.evil.com/webhook")

    @patch("socket.getaddrinfo")
    def test_allow_valid_public_ip(self, mock_getaddrinfo):
        mock_getaddrinfo.return_value = [
            (2, 1, 6, "", ("93.184.216.34", 80))
        ]
        try:
            validate_public_url("http://example.com/webhook")
        except ValidationError:
            self.fail("validate_public_url menolak IP publik yang valid.")

Batasan & Rekomendasi Tambahan

  • Egress Firewall: Defense-in-depth terbaik adalah membatasi akses keluar di level network layer. Konfigurasikan Security Group atau iptables di host Django agar memblokir trafik outbound ke 169.254.169.254 dan subnet lokal.
  • AWS IMDSv2: Wajibkan penggunaan token session IMDSv2 pada metadata instance EC2 (set HttpTokens=required dan batasi HttpPutResponseHopLimit=1) sehingga SSRF tanpa kontrol header custom tidak dapat mengekstrak kredensial.
  • Dedicated Proxy: Untuk throughput tinggi, isolasi penanganan request eksternal melalui egress forward proxy khusus (seperti Squid atau Envoy) yang menerapkan kontrol ACL terpusat.