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.1atau::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/16dan IPv6fe80::/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:
- Pengguna mendaftarkan URL
https://rebind.attacker.com. - Django memanggil
validate_public_url. DNS resolver attacker mengembalikan IP publik valid (misal93.184.216.34dengan TTL 0). Validasi form lolos. - Beberapa detik kemudian, worker Django mengeksekusi request HTTP via library standar (seperti
requests.get()). - 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.254dan subnet lokal. - AWS IMDSv2: Wajibkan penggunaan token session IMDSv2 pada metadata instance EC2 (set
HttpTokens=requireddan batasiHttpPutResponseHopLimit=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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!