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:
- Nilai header
HTTP_X_FORWARDED_HOST(hanya jika settingUSE_X_FORWARDED_HOST = True). - Nilai header standar
HTTP_HOST. - Variabel
SERVER_NAMEdari 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 mengaktifkanUSE_X_FORWARDED_HOST = Truekecuali reverse proxy Anda secara eksplisit mengesahkan dan membersihkan headerX-Forwarded-Hostdari 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_serveryang memutus koneksi dengan return code444. - Header Proxy: Menimpa
X-Forwarded-Hostdengan$hostresmi aplikasi. - Generasi URL: Menggunakan
domain_overridestatis atau modelSiteterisolasi untuk semua komunikasi email yang memuat token otentikasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!