Майже все, що приходить разом із файлом, задає клієнт: імʼя, розширення і Content-Type. Перевірка $_FILES['photo']['type'] === 'image/jpeg' порівнює рядок, який зловмисник сам написав у запиті. Тип визначають за вмістом: finfo читає сигнатуру файлу, і саме на нього спираються getMimeType() у Symfony та правила mimes і mimetypes у Laravel. Але і вміст не дає повної гарантії: файл може бути валідним JPEG і водночас містити PHP-код у коментарі EXIF чи після кінця зображення. Такий поліглот нешкідливий, поки його не виконують.
Виконання коду трапляється тоді, коли такий файл опиняється під розширенням, яке вебсервер передає в PHP, у теці, яку він обслуговує. Класика: move_uploaded_file() у public/uploads/ під оригінальним імʼям, і avatar.php стає веб-шеллом за адресою /uploads/avatar.php. Тому три правила йдуть разом: імʼя і розширення генерує сервер (store() у Laravel дає 40 випадкових символів і розширення за вмістом), файли лежать поза публічною текою (диск local у Laravel 11+ це storage/app/private), а в теці з публічними завантаженнями nginx не передає нічого в PHP-FPM. Оригінальне імʼя зберігають у базі лише для показу. PHP і Symfony обрізають шлях з імені файлу, але full_path з PHP 8.1, власні поля форми з назвою файлу і розпакування архівів (Zip Slip) цього не роблять, тож шлях на диску ніколи не будують з вводу.
Друга група ризиків стосується віддачі. SVG це XML, у якому може бути <script>; HTML і деякі PDF теж виконують код. Відкритий за прямим посиланням з вашого домену, такий файл отримує доступ до сесії так само, як XSS. Тому Laravel 13 у правилі image не пропускає SVG без явного allow_svg, приватні документи віддає контролер через Storage::download() з Content-Disposition: attachment і перевіркою доступу, а публічні файли краще роздавати з окремого домену чи CDN разом із X-Content-Type-Options: nosniff. Резюме чи скан паспорта за передбачуваним URL у storage/app/public це вже не завантаження файлів, а IDOR.
Розмір обмежують у трьох місцях, і вони мають узгоджуватися. nginx з client_max_body_size відповідає 413 до PHP. PHP відкидає тіло, більше за post_max_size, цілком, тож у контролері порожні і $_FILES, і $_POST, і помилки валідації виглядають дивно; а файл більший за upload_max_filesize приходить з кодом UPLOAD_ERR_INI_SIZE. Правило max у валідації дає користувачу зрозуміле повідомлення. Для картинок додають dimensions, щоб маленький файл на 20 000 на 20 000 пікселів не зʼїв памʼять при обробці, і перекодовують зображення, що заразом прибирає EXIF з координатами.
public function store(Request $request): RedirectResponse
{
$request->validate([
// mimes перевіряє вміст через finfo, а не імʼя файлу. max у кілобайтах.
'cv' => ['required', 'file', 'mimes:pdf', 'max:5120'],
// image без allow_svg не пропускає SVG; dimensions захищає від «бомб».
'avatar' => ['nullable', 'image', 'max:2048', 'dimensions:max_width=4000,max_height=4000'],
]);
// store() генерує випадкове імʼя з 40 символів і розширення за вмістом.
// Диск local у Laravel 11+ це storage/app/private, поза public/.
$path = $request->file('cv')->store('cvs', 'local');
$request->user()->resumes()->create([
'file_path' => $path,
// Оригінальне імʼя лише для показу, ніколи як шлях на диску.
'original_name' => Str::limit($request->file('cv')->getClientOriginalName(), 120, ''),
]);
return back();
}
// Віддача через контролер: перевірка доступу і Content-Disposition: attachment.
public function download(Resume $resume): StreamedResponse
{
Gate::authorize('view', $resume);
return Storage::disk('local')->download($resume->file_path, 'cv.pdf');
}
Опишіть шлях файлу: прийняли, перевірили вміст, дали своє імʼя, поклали туди, де PHP не виконується, віддаємо через перевірку доступу. На кожному кроці назвіть атаку, яку він закриває, і це буде відповідь рівня Middle+.