Celah Server-Side Request Forgery (SSRF) memungkinkan penyerang memaksa aplikasi backend melakukan request ke infrastruktur privat, seperti instance metadata service (IMDS) AWS pada IP 169.254.169.254 atau jaringan lokal (LAN). Pengujian manual sering kali terlewat saat release baru diluncurkan. Artikel ini mengulas implementasi automasi uji regresi SSRF berbasis pytest, lengkap dengan custom HTTP adapter untuk memverifikasi proteksi tanpa memicu traffic keluar aktual.
Vektor Serangan SSRF dan Mekanisme Validasi
Berdasarkan metodologi pengujian keamanan aplikasi (seperti pada repositori uphiago/recon-skills), SSRF berfokus pada eksploitasi fitur penarik data eksternal (misal: webhook parser, PDF generator, atau fetcher avatar). Vektor yang sering digunakan meliputi:
- Cloud Metadata IP: Menargetkan IP link-local seperti
http://169.254.169.254/latest/meta-data/untuk mencuri IAM credentials sementara. - IPv6 Encoded Bypass: Penggunaan format IPv6 seperti
[::ffff:169.254.169.254]atau[::1]untuk mengelabui regex filter IPv4 sederhana. - Open Redirect: URL awal mengarah ke domain publik legal, namun mengembalikan status HTTP
302 Foundmenuju endpoint internal. - DNS Rebinding: Domain publik dikonfigurasi dengan TTL rendah (0-1 detik) yang berganti dari IP publik ke IP privat antara waktu pengecekan dan waktu eksekusi.
Implementasi Safe HTTP Client
Pencegahan SSRF yang tepat membutuhkan validasi IP setelah resolusi DNS dan sebelum socket koneksi dibuka. Berikut adalah modul fetcher aman menggunakan pustaka standar ipaddress dan urllib.parse:
import ipaddress
import socket
from urllib.parse import urlparse
import requests
BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # Link-local / Cloud metadata
ipaddress.ip_network("::1/128"), # IPv6 localhost
ipaddress.ip_network("fc00::/7"), # IPv6 ULA
ipaddress.ip_network("fe80::/10"), # IPv6 Link-local
ipaddress.ip_network("::ffff:0:0/96"), # IPv4-mapped IPv6
]
class SSRFSecurityException(Exception):
pass
def resolve_and_validate_ip(hostname: str) -> str:
addr_info = socket.getaddrinfo(hostname, None)
if not addr_info:
raise SSRFSecurityException("Gagal meresolusi hostname.")
# Ambil IP pertama hasil resolusi DNS
target_ip_str = addr_info[0][4][0]
target_ip = ipaddress.ip_address(target_ip_str)
# Deteksi IPv4-mapped IPv6 (misal: ::ffff:127.0.0.1)
if isinstance(target_ip, ipaddress.IPv6Address) and target_ip.ipv4_mapped:
target_ip = target_ip.ipv4_mapped
for network in BLOCKED_NETWORKS:
if target_ip in network:
raise SSRFSecurityException(f"Akses ditolak: IP {target_ip} berada di zona terlarang.")
return str(target_ip)
def safe_fetch(url: str, session: requests.Session = None, max_redirects: int = 3) -> requests.Response:
if session is None:
session = requests.Session()
current_url = url
for _ in range(max_redirects + 1):
parsed = urlparse(current_url)
if parsed.scheme not in ["http", "https"]:
raise SSRFSecurityException(f"Skema protocol {parsed.scheme} tidak diizinkan.")
if not parsed.hostname:
raise SSRFSecurityException("Format URL tidak valid: hostname kosong.")
# Validasi resolusi DNS
resolve_and_validate_ip(parsed.hostname)
# Lakukan request tanpa mengikuti redirect otomatis
response = session.get(current_url, allow_redirects=False, timeout=5)
if response.is_redirect:
current_url = response.headers.get("Location")
if not current_url:
break
continue
return response
raise SSRFSecurityException("Melebihi batas maksimal redirect.")
Test Harness Menggunakan Pytest dan Mock HTTP Adapter
Agar pipeline CI/CD tidak melakukan koneksi keluar riil atau membutuhkan infrastruktur AWS asli, kita gunakan custom requests.adapters.HTTPAdapter untuk mensimulasikan respon IP metadata dan resolusi DNS internal.
import io
import pytest
from unittest.mock import patch
from requests.adapters import HTTPAdapter
from requests.models import Response
from urllib3.response import HTTPResponse
class MockCloudMetadataAdapter(HTTPAdapter):
def send(self, request, *args, **kwargs):
response = Response()
response.request = request
response.url = request.url
if "169.254.169.254" in request.url:
response.status_code = 200
response.raw = HTTPResponse(
body=io.BytesIO(b"{\"AccessKeyId\":\"AKIAFAKEKEY123\"}"),
status=200,
preload_content=False
)
elif "internal-redirect.local" in request.url:
response.status_code = 302
response.headers["Location"] = "http://169.254.169.254/latest/meta-data/"
response.raw = HTTPResponse(body=io.BytesIO(b""), status=302, preload_content=False)
else:
response.status_code = 200
response.raw = HTTPResponse(body=io.BytesIO(b"OK - Public"), status=200, preload_content=False)
return response
@pytest.fixture
def mocked_session():
session = requests.Session()
adapter = MockCloudMetadataAdapter()
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
Implementasi Test Suite Regresi SSRF
Suite pengujian berikut mengevaluasi boundary case: bypass IPv6, AWS IMDS, chain redirect, dan skenario DNS rebinding.
def test_block_direct_metadata_ip(mocked_session):
with pytest.raises(SSRFSecurityException, match="zona terlarang"):
safe_fetch("http://169.254.169.254/latest/meta-data/", session=mocked_session)
@pytest.mark.parametrize("bypass_url,resolved_ip", [
("http://[::ffff:169.254.169.254]/", "::ffff:169.254.169.254"),
("http://[::1]/secret", "::1"),
("http://localhost-alias.internal/", "127.0.0.1"),
])
def test_block_ipv6_and_internal_aliases(mocked_session, bypass_url, resolved_ip):
with patch("socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(2, 1, 6, "", (resolved_ip, 80))]
with pytest.raises(SSRFSecurityException, match="zona terlarang"):
safe_fetch(bypass_url, session=mocked_session)
def test_block_ssrf_via_redirect(mocked_session):
# Simulasi domain publik yang mengembalikan HTTP 302 ke IP metadata
with patch("socket.getaddrinfo") as mock_dns:
# Panggilan DNS pertama untuk publik IP, DNS kedua untuk target redirect
mock_dns.side_effect = [
[(2, 1, 6, "", ("93.184.216.34", 80))], # IP publik valid
[(2, 1, 6, "", ("169.254.169.254", 80))] # Link-local dilarang
]
with pytest.raises(SSRFSecurityException, match="zona terlarang"):
safe_fetch("http://internal-redirect.local/step1", session=mocked_session)
def test_dns_rebinding_prevention(mocked_session):
# Simulasi pergantian resolusi IP antara validasi awal dan koneksi
with patch("socket.getaddrinfo") as mock_dns:
mock_dns.side_effect = [
[(2, 1, 6, "", ("1.1.1.1", 80))], # Lookup pertama lolos
[(2, 1, 6, "", ("192.168.1.1", 80))] # Rebinding ke IP privat
]
# Request validasi awal lolos
assert resolve_and_validate_ip("rebind.attacker.domain") == "1.1.1.1"
# Saat lookup kedua terjadi, request wajib diblokir
with pytest.raises(SSRFSecurityException):
resolve_and_validate_ip("rebind.attacker.domain")
Integrasi pada Pipeline CI/CD
Jalankan test harness secara konsisten pada pipeline integrasi (misalnya GitHub Actions atau GitLab CI) untuk mendeteksi regresi sebelum kode di-merge ke branch utama:
# Cuplikan konfigurasi CI (.github/workflows/security-test.yml)
name: Security Regression Tests
on: [push, pull_request]
jobs:
ssrf-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install pytest requests
- name: Run SSRF Regression Suite
run: |
pytest -v tests/test_ssrf_regression.py
Catatan Produksi: Untuk proteksi menyeluruh terhadap DNS Rebinding pada level sistem operasi, implementasikan pinned IP connection (membuka socket langsung ke IP yang divalidasi dengan header
Hostasli) atau gunakan firewall egress/network proxy (seperti Smokescreen) yang memvalidasi koneksi keluar di layer transport.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!