Subjek commit perbaikan untuk folder lampiran ini saya tulis dalam satu kalimat bahasa Inggris, dan kalimat itu sebenarnya sudah merangkum seluruh ceritanya: "the attachment directory was protected by an Apache error, not by its deny rule". Folder itu memang tertutup sejak awal. Hanya saja yang menutupnya bukan aturan yang saya tulis untuk menutupnya.
Konteksnya sebuah form yang boleh dikirim secara anonim dan boleh membawa lampiran. Di server itu, unggahan lampiran adalah satu-satunya jalur tulis anonim, jadi seluruh permukaannya saya kumpulkan di satu berkas PHP dengan empat aturan: isi berkas dicek terhadap ekstensinya, nama simpan dibangkitkan, foldernya ditolak lewat .htaccess, dan satu batch unggahan diterima semua atau tidak sama sekali.
Folder lampiran itu dibuat di dalam basedir unggahan WordPress, dan komentar di kodenya menyebut alasannya terang-terangan: Apache yang melayani pohon direktori itu, jadi aturan deny ditegakkan di sana. Lampiran mendarat di direktori tersendiri yang ditolak lewat .htaccess dan berisi index.html, sehingga tebakan nama yang benar pun semestinya ditolak web server. Direktori yang tidak bisa dilindungi diperlakukan sebagai error, bukan diberi jalan mundur, karena menyimpan unggahan anonim di tempat yang bisa dibaca lebih buruk daripada menolak lampirannya. Kepala berkasnya menambahkan bahwa secara desain tidak ada yang bisa dieksekusi di folder itu, dengan aturan deny sebagai sabuk dan nama yang dibangkitkan sebagai bretelnya, dan bahwa server itu juga menampung klien lain.
Aturan yang ditulis ke .htaccess
Isi .htaccess itu dirangkai sebagai string PHP. Di versi pertama, aturan yang dihasilkan string itu berbunyi begini:
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
php_flag engine offDua blok pertama adalah pola deny yang lazim: Require all denied untuk Apache yang memuat mod_authz_core, dan Order plus Deny from all untuk yang tidak. Baris terakhir berdiri sendiri tanpa penjaga apa pun. Di bawah mod_php, php_flag engine off mematikan mesin PHP untuk folder itu, dan baris seperti ini lazim muncul di saran pengamanan folder unggahan.
Masalahnya, php_flag hanya sah di bawah mod_php, sedangkan host ini menjalankan PHP lewat FastCGI. Direktif php_flag dan php_value didaftarkan oleh modul PHP untuk Apache. Kalau PHP berjalan sebagai proses FastCGI terpisah, modul itu umumnya tidak dimuat, dan kata php_flag tidak berarti apa-apa bagi Apache. Secara bawaan, direktif yang tidak dikenal di .htaccess adalah kesalahan konfigurasi. Akibatnya, Apache gagal mem-parse berkas itu dan membalas 500 untuk semua isi folder.
Di error log, kegagalan semacam ini biasanya tercatat dengan bunyi Invalid command 'php_flag', perhaps misspelled or defined by a module not included in the server configuration. Catatan saya tidak menyimpan isi log server ini, jadi baris itu saya sebut sebagai bentuk umum pesannya, bukan kutipan dari kasus ini. Kalau pesan itu muncul untuk sebuah .htaccess, seluruh berkas itu sedang tidak berlaku.
500 yang terbaca seperti perlindungan
Dari luar, hasilnya mirip dengan yang saya mau. Semua isi direktori itu membalas 500, jadi tidak ada lampiran yang bisa diambil lewat web, dan itu terbaca seperti folder yang terlindungi. Padahal perlindungannya kebetulan, dan aturan deny-nya tidak pernah tercapai.
Masalahnya ada pada pertanyaan yang diajukan. Kalau sebuah pemeriksaan hanya bertanya apakah berkas bisa diunduh, 500 dan 403 sama-sama menjawab tidak, padahal artinya jauh berbeda. Status 403 berarti aturan deny yang menjawab. Status 500 berarti Apache tidak selesai membaca konfigurasi folder itu, dan tidak satu pun aturan di dalamnya sedang berlaku.
Pesan commit perbaikan saya menjelaskannya dengan kalimat bahwa Apache yang berhenti pada direktif buruk juga tidak pernah sampai ke Require all denied di bawahnya. Satu detail di kalimat itu tidak cocok dengan berkasnya sendiri. Di string versi pertama, php_flag justru baris terakhir, di bawah kedua blok deny. Jadi yang menggagalkan deny bukan urutannya. Satu direktif yang tidak dikenal membuat Apache menolak seluruh .htaccess, termasuk aturan yang ditulis di atasnya, dan kasus ini memperlihatkannya sendiri: blok Require all denied ada di atas php_flag, dan direktori itu tetap membalas 500. Memindahkan baris yang berisiko ke paling bawah tidak menyelamatkan apa pun.
Penjaga per modul, lalu 403 yang sungguhan
Perbaikannya kecil. Baris php_flag engine off yang berdiri sendiri dihapus, lalu direktif yang sama dibungkus tiga blok <IfModule> terpisah, satu untuk tiap nama modul PHP:
- . "php_flag engine off\n";
+ . "<IfModule mod_php.c>\n php_flag engine off\n</IfModule>\n"
+ . "<IfModule mod_php7.c>\n php_flag engine off\n</IfModule>\n"
+ . "<IfModule mod_php8.c>\n php_flag engine off\n</IfModule>\n";Di atas string itu sekarang ada komentar yang berisi kalimat php_flag has to be guarded. Isi blok <IfModule> hanya dibaca kalau modul yang disebut sedang dimuat. Di host ini mod_php jelas tidak dimuat, dan itu bukan tebakan: seandainya dimuat, php_flag adalah direktif yang sah dan Apache tidak akan gagal mem-parse berkasnya. Jadi ketiga blok itu sekarang dilewati, dan php_flag engine off tidak melakukan apa pun di host ini. Yang menutup folder itu adalah aturan deny-nya, ditambah desain yang sejak awal tidak menaruh apa pun yang bisa dieksekusi di sana.
Hasilnya saya verifikasi di produksi dengan sebuah berkas uji di direktori itu. Sesudah perbaikan, berkas itu membalas 403 dengan aturan deny yang benar-benar berlaku, padahal sebelumnya 500.
Satu bagian lain dari berkas yang sama relevan di sini. .htaccess sengaja ditulis setiap kali, bukan sekali, karena restore atau sinkronisasi yang menjatuhkannya akan membuka semua isi folder tanpa tanda. Pemeriksaannya membandingkan md5:
if (!file_exists($htaccess) || md5_file($htaccess) !== md5($rules)) {Secara logika kode, begitu isi $rules berubah, berkas lama tidak lagi lolos perbandingan md5 dan kode akan menulisnya ulang saat berjalan berikutnya. Catatan saya tidak merekam bagaimana berkas lama di server sebenarnya terganti, jadi bagian itu saya biarkan sebagai logika kode, bukan kejadian yang tercatat.
Batas 2MB yang menolak sebelum kode sempat berjalan
Spesifikasi lampirannya menyusul belakangan. Di ronde revisi 3 Agustus, butir itu masih sengaja dibiarkan terbuka dengan catatan bahwa unggahan berkas belum punya spesifikasi. Spesifikasinya kemudian datang dari balasan klien: paling banyak tiga berkas, 2MB, dalam format PDF, Word, PNG, atau JPG, dan PowerPoint yang ada di daftar usulan saya dicoret klien.
Batas unggah di host ini juga 2MB. max_bytes di kode sengaja disamakan dengan upload_max_filesize host, karena PHP membuang berkas yang lebih besar sebelum kode modul itu sempat berjalan. Menulis angka yang lebih tinggi di kode tidak akan membuat host menerima berkas yang lebih besar.
Yang lebih licin adalah post_max_size. POST yang melewatinya tiba dengan $_POST kosong, sehingga action form tidak pernah dirutekan dan handler sama sekali tidak berjalan. Komentar di template form menggambarkan akibatnya dari sisi pengirim: form akan terlihat seperti tidak melakukan apa-apa. Karena itu batasnya dicantumkan di form, dan batas yang sama ditegakkan juga di main.js dengan komentar ini:
// The file rules are checked here as well as on the server because they
// cannot always be checked there: a POST over the host's post_max_size
// arrives with an empty $_POST, so the action never routes and the handler
// never runs. Blocking it before submit is the only way the sender gets
// told anything at all.Validasi di peramban biasanya hanya kenyamanan karena server tetap memeriksa ulang. Untuk kiriman yang melewati post_max_size, pemeriksaan di server itu tidak pernah terjadi.
Yang dicari adalah 403, bukan sekadar gagal diunduh
Pemeriksaan folder privat perlu menuntut status persisnya: 403, dari aturan deny yang memang berlaku, seperti yang akhirnya terlihat di produksi. Status 500 di folder yang seharusnya ditolak bukan variasi dari aman, melainkan tanda ada yang gagal sebelum aturanmu sempat menjawab.
Untuk direktif yang hanya dikenal satu modul, seperti php_flag dan php_value, bungkus dengan <IfModule> sebelum menaruhnya di .htaccess, terutama kalau berkas itu dibangkitkan kode dan bisa berpindah ke host yang menjalankan PHP dengan cara lain. Urutan baris juga tidak bisa diandalkan, karena direktif yang tidak dikenal menjatuhkan seluruh berkas, termasuk aturan di atasnya.
Folder lampiran ini tertutup sebelum dan sesudah perbaikan. Bedanya, sesudah perbaikan saya tahu apa yang menutupnya.