wp-content/uploads yang bisa dipanggil langsung dari browser. Padahal file .htaccess yang dulu memblokir PHP di folder itu masih ada di tempatnya. Rule-nya tidak rusak. Nginx memang tidak pernah membacanya.
Tulisan ini disusun seperti post-mortem untuk pola insiden yang cukup sering muncul setelah migrasi semacam ini. Ini bukan catatan satu kejadian tertentu, tapi urutan yang biasanya dilewati saat menelusuri masalahnya: dampak awal, jalur investigasi, titik temuan, mitigasi cepat, lalu perbaikan yang lebih permanen.
Dampak awal: rule lama masih ada, tapi tidak ada yang membaca
wp-content/uploads harus bisa ditulisi oleh WordPress, karena di situlah gambar dan file media disimpan. Artinya, kalau ada plugin atau form upload yang punya celah, penyerang bisa menaruh file .php di sana. Selama PHP tidak dieksekusi di folder itu, file tersebut cuma teks yang tidak berbuat apa-apa. Begitu PHP bisa jalan, file itu berubah jadi webshell, yaitu skrip kecil yang memberi penyerang akses ke server lewat browser.
Webshell yang jalan memakai user PHP-FPM yang sama dengan situs. Dari situ dia bisa membaca wp-config.php beserta kredensial database, mengubah file tema dan plugin, menyisipkan redirect, atau mengirim spam. Latar belakang kenapa folder ini berisiko sudah dibahas di artikel Mengira Folder uploads WordPress Aman Meski PHP Masih Bisa Dijalankan di Sana.
Yang membuat kasus ini licin adalah tidak ada tanda error sama sekali. Situs tetap cepat, nginx -t lolos, dan tidak ada warning soal .htaccess. Dari sisi support, gejala yang biasanya dilaporkan malah efek sampingnya: pengunjung dilempar ke situs lain, ada laporan abuse karena spam, CPU naik tanpa trafik yang jelas, atau scanner keamanan menandai file asing.
Jejak investigasi: dari file asing sampai config Nginx
- Konfirmasi gejala dan catat kapan mulai terlihat.
- Cari file PHP di folder yang seharusnya hanya berisi media.
- Cek access log untuk melihat apakah file itu pernah dipanggil, dan apa status responsnya.
- Lihat apakah
.htaccesspemblokir masih ada di folderuploads. - Uji langsung apakah PHP di folder itu dieksekusi oleh server yang sekarang.
BASH
# Cari file PHP di folder uploads (read-only)
find /path/ke/wordpress/wp-content/uploads -type f -iname '*.php'
# File yang berubah dalam 7 hari terakhir
find /path/ke/wordpress/wp-content/uploads -type f -mtime -7 -ls
# Request ke file PHP di uploads yang tercatat di access log
grep -E 'wp-content/uploads/.*\.php' /var/log/nginx/access.log
# Uji eksekusi: buat file uji yang tidak berbahaya, panggil, lalu hapus
printf '<?php echo 7*6;' > /path/ke/wordpress/wp-content/uploads/uji-eksekusi.php
curl -i https://domain-kamu.tld/wp-content/uploads/uji-eksekusi.php
rm /path/ke/wordpress/wp-content/uploads/uji-eksekusi.php42, PHP dieksekusi di folder uploads dan itu masalahnya. Kalau yang muncul isi kode mentahnya, PHP tidak dieksekusi, tapi ada konfigurasi lain yang perlu dicek. Kalau statusnya 403, aksesnya sudah diblokir. Pastikan file uji langsung dihapus setelah tes, jangan ditinggal.
Di access log, perhatikan request POST atau GET ke file .php di bawah uploads dengan status 200. Status 200 di path itu adalah tanda yang perlu ditindaklanjuti, karena artinya server menjalankan file tersebut dan mengembalikan respons.
Titik temuan: Nginx memang tidak mengenal .htaccess
.htaccess adalah konfigurasi per direktori. Kalau AllowOverride mengizinkan, Apache mengecek file .htaccess di setiap folder sepanjang path yang diminta, di setiap request. Itulah kenapa satu file kecil di uploads cukup untuk memblokir PHP di folder itu saja, tanpa menyentuh config server.
Nginx sengaja tidak punya mekanisme itu. Seluruh konfigurasi dibaca terpusat saat Nginx start atau reload, lalu dipakai apa adanya. Tidak ada override per folder, dan file .htaccess diperlakukan seperti file biasa. Pilihan desain ini membuat Nginx lebih ringan dan perilakunya lebih mudah diprediksi, tapi konsekuensinya semua aturan di .htaccess hilang begitu saja saat migrasi kalau tidak diterjemahkan manual. Soal ini juga dibahas lebih luas di Mengira File .htaccess Masih Berguna Saat Pindah dari Apache ke Nginx.
Lalu kenapa file di uploads bisa jalan? Config WordPress di Nginx umumnya punya satu blok seperti location ~ \.php$ yang meneruskan semua request berakhiran .php ke PHP-FPM. Blok itu tidak peduli foldernya. Selama path-nya berakhir .php dan file-nya ada, request akan dieksekusi. Rule pemblokir yang dulu ada di .htaccess tidak punya padanan di config baru, jadi tidak ada yang menghalangi.
Ada satu pengecualian yang perlu dipastikan. Kalau Nginx hanya berperan sebagai reverse proxy di depan Apache dan request PHP tetap diteruskan ke Apache, .htaccess masih dibaca oleh Apache. Jadi sebelum menyimpulkan, cek dulu siapa yang benar-benar menangani PHP: Nginx langsung ke PHP-FPM, atau Nginx ke Apache.
Mitigasi sementara: tutup eksekusi PHP di uploads dulu
server situs tersebut:
NGINX
# Taruh di dalam blok server, SEBELUM location ~ \.php$
location ~* ^/wp-content/uploads/.*\.(php|phtml|phar)$ {
deny all;
}~ dan ~*) sesuai urutan kemunculannya di config, dan yang pertama cocok yang dipakai. Kalau blok location ~ \.php$ ada di atasnya, request ke uploads akan lebih dulu cocok dengan blok PHP umum dan rule pemblokir tidak pernah tersentuh. Kalau WordPress kamu ada di subfolder atau path uploads-nya diubah, sesuaikan regex-nya.
Setelah itu, validasi config sebelum reload, lalu ulangi uji eksekusi tadi:
BASH
sudo nginx -t && sudo systemctl reload nginx
# Ulangi uji: buat file uji, harapkan status 403, lalu hapus
printf '<?php echo 7*6;' > /path/ke/wordpress/wp-content/uploads/uji-eksekusi.php
curl -i https://domain-kamu.tld/wp-content/uploads/uji-eksekusi.php
rm /path/ke/wordpress/wp-content/uploads/uji-eksekusi.php- Pindahkan file PHP asing dari uploads ke folder karantina di luar webroot. Jangan langsung dihapus, karena isinya berguna untuk melihat apa saja yang bisa dilakukan file itu.
- Cek apakah ada file yang berubah di tema, plugin, dan root WordPress pada rentang waktu yang sama.
- Anggap
wp-config.phpsudah terbaca. Ganti password database, regenerasi salt WordPress, dan reset password akun admin. - Cari tahu jalur masuknya, biasanya plugin atau tema dengan celah upload. Selama celahnya masih terbuka, file baru bisa terus datang.
Perbaikan permanen: aturan keamanan pindah ke config server
.htaccess sekarang harus hidup di config Nginx, dan itu perlu jadi bagian dari proses, bukan perbaikan dadakan.
Kalau server menampung banyak situs WordPress, simpan rule pemblokir itu di satu file snippet, lalu include dari setiap blok server. Di Debian dan Ubuntu, folder /etc/nginx/snippets/ biasa dipakai untuk ini, tapi lokasinya bebas selama konsisten. Dengan begitu, setiap vhost baru otomatis mendapat perlindungan yang sama, dan perubahan cukup dilakukan di satu tempat.
Kalau config Nginx dibuat oleh control panel, hati-hati menyunting file vhost langsung. Banyak panel menulis ulang file itu saat ada perubahan domain atau SSL, sehingga rule tambahan bisa hilang. Cari mekanisme custom include yang disediakan panelnya, karena caranya berbeda di tiap panel.
Untuk migrasi berikutnya, jadikan inventaris .htaccess sebagai langkah wajib sebelum cutover. Cari semua file .htaccess di webroot, baca isinya satu per satu, lalu tentukan padanannya di Nginx. Rule rewrite biasanya langsung ketahuan kalau tidak diterjemahkan karena halaman jadi 404. Rule keamanan seperti blok PHP di uploads, proteksi folder, atau pembatasan akses file sensitif tidak memberi tanda apa pun saat hilang, jadi kelompok inilah yang paling perlu dicek dengan uji akses seperti di atas.
Sebagai lapisan tambahan, cek juga security.limit_extensions di pool PHP-FPM. Directive ini membatasi ekstensi file yang mau dieksekusi oleh PHP-FPM, sehingga file seperti gambar.jpg tidak ikut dijalankan sebagai PHP kalau ada salah konfigurasi di Nginx. Nilai default-nya bisa berbeda antar versi dan paket distro, jadi lihat langsung di config pool kamu.
Intinya, .htaccess di server Nginx hanyalah file teks biasa. Setiap kali selesai memindahkan WordPress dari Apache, jalankan uji eksekusi PHP di folder uploads dan pastikan hasilnya 403, bukan 42. Tes itu cuma butuh beberapa detik, dan hasilnya langsung menjawab apakah perlindungan lama ikut pindah atau tertinggal di server sebelumnya.