Anatomi Ancaman: Bypass Ekstensi dan Berkas Polyglot
Memvalidasi berkas hanya berdasarkan ekstensi nama berkas (seperti .jpg atau .png) dan header Content-Type kiriman peramban adalah celah keamanan fatal. Nilai header tersebut dikontrol sepenuhnya oleh klien dan dapat dimanipulasi dengan mudah menggunakan proksi HTTP seperti Burp Suite.
Ancaman yang lebih berbahaya adalah file polyglot. Berkas polyglot valid secara struktural dalam dua format berbeda sekaligus. Penyerang dapat membuat berkas yang memiliki magic bytes GIF (GIF89a) atau JPEG di awal berkas, namun menyisipkan payload PHP executable (<?php phpinfo(); ?>) pada segmen komentar EXIF. Jika server web mengizinkan eksekusi skrip di direktori upload, atau jika peramban melakukan MIME sniffing terhadap berkas berheader keliru, payload tersebut akan dieksekusi.
Frontend: Implementasi Form Upload dengan Inertia.js
Di sisi frontend, gunakan helper useForm dari @inertiajs/vue3. Helper ini menangani transformasi objek FormData secara otomatis, melacak progres transfer data, dan memetakan respons status HTTP 422 langsung ke properti errors.
<script setup>
import { useForm } from '@inertiajs/vue3';
const form = useForm({
avatar: null,
});
function handleFileChange(event) {
form.avatar = event.target.files[0];
}
function submit() {
form.post('/profile/avatar', {
forceFormData: true,
preserveScroll: true,
onError: () => {
form.reset('avatar');
},
});
}
</script>
<template>
<form @submit.prevent="submit">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
@change="handleFileChange"
/>
<div v-if="form.progress" class="progress-bar">
<progress :value="form.progress.percentage" max="100">
{{ form.progress.percentage }}%
</progress>
</div>
<p v-if="form.errors.avatar" class="error-text">
{{ form.errors.avatar }}
</p>
<button type="submit" :disabled="form.processing">
Simpan Foto
</button>
</form>
</template>Backend: Deteksi Magic Byte dan Purifikasi Berkas
Pada backend Laravel, validasi upload harus melalui tiga lapis pertahanan:
- Inspeksi Magic Bytes: Membaca signature byte fisik berkas via ekstensi
fileinfoPHP alih-alih membaca header HTTP. - Re-encoding Citra: Membaca ulang matriks piksel citra dan menuliskannya kembali ke berkas baru. Proses ini menghapus seluruh metadata EXIF dan merusak payload polyglot yang tersembunyi di dalam segmen non-standar.
- Randomisasi Nama Berkas: Mengabaikan nama asli dari klien guna mencegah eksploitasi path traversal atau penimpaan berkas sistem.
Implementasi Form Request
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreAvatarRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'avatar' => [
'required',
'file',
'max:2048', // 2MB
'mimes:jpg,jpeg,png,webp', // Memanfaatkan finfo PHP di latar belakang
],
];
}
}Controller: Pembersihan dan Penyimpanan Terisolasi
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreAvatarRequest;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ProfileAvatarController extends Controller
{
public function store(StoreAvatarRequest $request)
{
$uploadedFile = $request->file('avatar');
$realPath = $uploadedFile->getRealPath();
// 1. Verifikasi ketat Magic Byte via native finfo
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($realPath);
$allowedMimes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!array_key_exists($mimeType, $allowedMimes)) {
return back()->withErrors([
'avatar' => 'Format berkas tidak valid atau terdeteksi korup.',
]);
}
// 2. Netralisasi Polyglot: Re-encoding citra menggunakan GD
$rawContent = file_get_contents($realPath);
$imageResource = @imagecreatefromstring($rawContent);
if ($imageResource === false) {
return back()->withErrors([
'avatar' => 'Struktur biner citra rusak atau disusupi.',
]);
}
// Simpan ke buffer memory baru untuk menghapus EXIF dan shell payload
ob_start();
imagejpeg($imageResource, null, 85);
$cleanImageData = ob_get_clean();
imagedestroy($imageResource);
// 3. Nama berkas acak & penyimpanan di luar webroot publik
$secureFileName = Str::random(40) . '.' . $allowedMimes[$mimeType];
$storagePath = 'private/avatars/' . $secureFileName;
// Simpan ke disk non-publik
Storage::disk('local')->put($storagePath, $cleanImageData);
auth()->user()->update([
'avatar_path' => $storagePath,
]);
return back()->with('success', 'Foto profil berhasil diperbarui.');
}
}Penyajian Berkas Aman via Signed URL
Berkas yang diunggah dilarang ditempatkan di direktori public/storage atau direktori yang dapat diakses langsung oleh web server. Penempatan langsung memungkinkan eksekusi langsung jika terjadi kesalahan konfigurasi Nginx/Apache. Gunakan route terproteksi dengan validasi tanda tangan digital (signed route) dan sematkan header proteksi X-Content-Type-Options: nosniff.
Rute dan Handler Unduhan
// routes/web.php
Route::get('/media/avatars/{filename}', [MediaController::class, 'showAvatar'])
->name('media.avatar')
->middleware('signed');<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class MediaController extends Controller
{
public function showAvatar(Request $request, string $filename)
{
// Cegah Directory Traversal
$safeFilename = basename($filename);
$path = 'private/avatars/' . $safeFilename;
abort_unless(Storage::disk('local')->exists($path), 404);
return Storage::disk('local')->response($path, $safeFilename, [
'Content-Type' => 'image/jpeg',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => "default-src 'none'",
'Cache-Control' => 'private, no-transform, max-age=86400',
]);
}
}Untuk menghasilkan URL sementara di Inertia controller atau API resource:
use Illuminate\Support\Facades\URL;
$avatarUrl = URL::temporarySignedRoute(
'media.avatar',
now()->addMinutes(30),
['filename' => basename($user->avatar_path)]
);Trade-off dan Limitasi
- Beban CPU (Re-encoding): Operasi
imagecreatefromstringmemakan memori dan siklus CPU. Untuk berkas berdimensi masif, proses ini harus dilempar ke latar belakang via Queue Job sebelum berkas berstatus aktif. - Kehilangan Metadata Asli: Re-encoding secara otomatis menghapus metadata GPS, orientasi kamera, dan profil warna. Jika orientasi gambar terbalik, gunakan
imagerotateberdasarkan flag EXIF sebelum fungsi penulisan ulang dijalankan. - Berkas Non-Gambar: Untuk PDF atau format non-gambar, re-encoding citra tidak dapat diterapkan. Mitigasi dialihkan pada scanning antivirus (ClamAV) dan penyajian wajib dengan header
Content-Disposition: attachment.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!