Serangan Host Header Poisoning terjadi ketika aplikasi web mempercayai nilai header HTTP Host yang dikirim oleh klien secara mentah untuk membangun URL sensitif, seperti tautan reset password. Ketika penyerang memasukkan domain berbahaya ke dalam header tersebut saat meminta reset password untuk alamat email korban, Django dapat menyusun URL verifikasi yang mengarah ke server penyerang. Begitu korban mengklik tautan dari inbox mereka, token rahasia reset password terkirim langsung ke server penyerang melalui header Referer atau parameter URL.

Vulnerabilitas Host Header Poisoning pada Reset Password Django

Secara default, jika aplikasi tidak dikonfigurasi dengan benar, pembuatan tautan reset password bergantung pada method request.get_host(). Method internal Django ini mengevaluasi host berdasarkan prioritas berikut:

  1. Nilai header HTTP_X_FORWARDED_HOST (hanya jika setting USE_X_FORWARDED_HOST = True).
  2. Nilai header standar HTTP_HOST.
  3. Variabel SERVER_NAME dari environment server.

Jika modul django.contrib.sites tidak terpasang atau tidak dikonfigurasi dengan ID statis, Django memanggil fallback ke RequestSite(request), yang mengambil domain langsung dari request.get_host(). Kombinasi dari ketergantungan dinamis ini dan celah di layer proxy membuka vektor eksploitasi Host Header Poisoning.

Peringatan Keamanan: Jangan pernah mengaktifkan USE_X_FORWARDED_HOST = True kecuali reverse proxy Anda secara eksplisit mengesahkan dan membersihkan header X-Forwarded-Host dari klien eksternal sebelum meneruskannya ke Django.

1. Hardening Pengaturan ALLOWED_HOSTS

Django menyediakan proteksi lapis pertama melalui validasi ALLOWED_HOSTS. Jika header Host tidak cocok dengan daftar ini, Django melempar eksepsi django.core.exceptions.DisallowedHost dan membatalkan request dengan status HTTP 400.

Hindari penggunaan wildcard permisif di server produksi:

# JANGAN GUNAKAN INI DI PRODUKSI
ALLOWED_HOSTS = ['*']

# JANGAN GUNAKAN PREFIX WILDCARD JIKA SUBDOMAIN DIKELOLA OLEH PIHAK KETIGA
ALLOWED_HOSTS = ['.example.com']

# KONFIGURASI YANG BENAR: Daftarkan FQDN secara eksplisit
ALLOWED_HOSTS = [
    'app.example.com',
    'admin.example.com',
]

Jika aplikasi multi-tenant mengharuskan penggunaan subdomain dinamis, validasi subdomain tersebut terhadap daftar entitas database sebelum memproses request, alih-alih membuka seluruh domain via wildcard tanpa kontrol.

2. Konfigurasi Reverse Proxy Nginx untuk Sanitasi Header

Penyerang sering memanipulasi header Host atau menginjeksi X-Forwarded-Host langsung ke reverse proxy. Nginx bertindak sebagai garis pertahanan terdepan untuk memastikan Django hanya menerima metadata yang valid.

Konfigurasikan Nginx agar menolak request dengan host yang tidak dikenal menggunakan default server block, serta menimpa header proxy secara ketat:

# 1. Tolak request tanpa Host yang valid atau Host yang tidak dikenali
server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    
    # Hentikan koneksi tanpa memberikan respons payload
    return 444;
}

# 2. Blok server resmi aplikasi
server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate /etc/ssl/certs/app.example.com.crt;
    ssl_certificate_key /etc/ssl/private/app.example.com.key;

    location / {
        proxy_pass http://unix:/run/gunicorn.sock;
        
        # Kunci Host ke server_name yang telah divalidasi Nginx
        proxy_set_header Host $host;
        
        # Timpa header forwarding liar dari klien publik
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Dengan konfigurasi ini, jika penyerang mengirim X-Forwarded-Host: attacker.com, Nginx akan menimpa nilai tersebut dengan variabel internal $host (app.example.com) sebelum mencapai upstream WSGI/ASGI.

3. Isolasi Domain Reset Password dari Header Request

Pendekatan defensif terbaik adalah memutus dependensi alur reset password terhadap HTTP request header. Gunakan domain statis dari pengaturan atau tabel database yang terisolasi via django.contrib.sites.

Opsi A: Menggunakan django.contrib.sites dengan SITE_ID Statis

Pastikan django.contrib.sites terpasang di INSTALLED_APPS dan tetapkan SITE_ID secara deterministik di settings.py:

# settings.py
INSTALLED_APPS = [
    # ...
    'django.contrib.sites',
]

SITE_ID = 1

Pastikan record pada tabel database django_site untuk ID 1 bernilai app.example.com. PasswordResetForm standar Django akan memprioritaskan domain dari database ini dibanding membaca request dinamis.

Opsi B: Override Domain secara Eksplisit pada View

Jika menggunakan view kustom atau API authentication terpisah (misalnya Django REST Framework), suntikkan domain tepercaya secara manual via argumen domain_override:

# views.py
from django.conf import settings
from django.contrib.auth.views import PasswordResetView

class HardenedPasswordResetView(PasswordResetView):
    def form_valid(self, form):
        # Abaikan host dari request, gunakan domain yang dikunci di konfigurasi
        opts = {
            'use_https': True,
            'domain_override': settings.CANONICAL_APP_DOMAIN,
            'request': self.request,
        }
        form.save(**opts)
        return super().form_valid(form)

4. Pengujian Otomatis Menggunakan RequestFactory

Untuk memastikan proteksi berjalan stabil dan tidak rusak saat deployment masa depan, buat unit test yang menyimulasikan injeksi header berbahaya.

from django.test import TestCase, RequestFactory, override_settings
from django.core.exceptions import DisallowedHost
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordResetForm
from django.core import mail

User = get_user_model()

class PasswordResetHostSecurityTest(TestCase):
    def setUp(self):
        self.factory = RequestFactory()
        self.user = User.objects.create_user(
            username="korban",
            email="[email protected]",
            password="secret-password-123"
        )

    @override_settings(ALLOWED_HOSTS=['app.example.com'])
    def test_disallowed_host_injection_raises_exception(self):
        """Memverifikasi header Host palsu memicu DisallowedHost."""
        request = self.factory.post(
            '/accounts/password_reset/',
            HTTP_HOST='attacker.com'
        )
        
        with self.assertRaises(DisallowedHost):
            request.get_host()

    def test_password_reset_email_uses_override_domain(self):
        """Memverifikasi link email tidak dapat dikontaminasi oleh input request."""
        form = PasswordResetForm(data={'email': '[email protected]'})
        self.assertTrue(form.is_valid())

        # Eksekusi save dengan domain_override yang aman
        trusted_domain = 'app.example.com'
        form.save(
            domain_override=trusted_domain,
            use_https=True,
            subject_template_name='registration/password_reset_subject.txt',
            email_template_name='registration/password_reset_email.html'
        )

        self.assertEqual(len(mail.outbox), 1)
        email_body = mail.outbox[0].body
        
        # Domain resmi harus ada di dalam tautan token
        self.assertIn(f'https://{trusted_domain}/', email_body)
        # Domain penyerang tidak boleh masuk ke payload email
        self.assertNotIn('attacker.com', email_body)

Checklist Verifikasi Keamanan

  • ALLOWED_HOSTS: Berisi daftar domain eksplisit tanpa wildcard '*'.
  • Nginx: Memiliki server block default_server yang memutus koneksi dengan return code 444.
  • Header Proxy: Menimpa X-Forwarded-Host dengan $host resmi aplikasi.
  • Generasi URL: Menggunakan domain_override statis atau model Site terisolasi untuk semua komunikasi email yang memuat token otentikasi.