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 Found menuju 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 Host asli) atau gunakan firewall egress/network proxy (seperti Smokescreen) yang memvalidasi koneksi keluar di layer transport.