<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · MIDDLE ЧАСТО ПИТАЮТЬ

Як безпечно приймати файли від користувача в PHP і Laravel?

Розширення, імʼя і MIME-тип із запиту задає клієнт, тож тип файлу визначають за вмістом (`finfo`, правило `mimes` у Laravel), імʼя і розширення генерують самі, а файл кладуть поза публічною текою, де вебсервер не виконає PHP. Віддають файли через контролер з перевіркою доступу або з окремого домену, а розмір обмежують на рівні nginx, PHP і валідації.

Користувач завантажив `avatar.php` замість `avatar.png`, і сервер його виконав. Що пішло не так і де саме?
Ми перевіряємо `$_FILES['photo']['type'] === 'image/jpeg'`. Цього досить?
Чому SVG-аватарки небезпечніші за PNG?
Файл на 20 МБ приходить, а в `$_FILES` і `$_POST` порожньо. Чому?
file upload mimes finfo storage SVG upload_max_filesize

Майже все, що приходить разом із файлом, задає клієнт: імʼя, розширення і 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');
}
Що `$_FILES[...]['type']`, `getClientMimeType()` і `getClientOriginalExtension()` приходять від клієнта і нічого не гарантують, а `getMimeType()` і правило `mimes` дивляться на вміст через `finfo`.
Що виконання коду можливе, лише коли файл з PHP-кодом опиняється під розширенням, яке вебсервер віддає інтерпретатору, тому імʼя і розширення генерує сервер, а теку з завантаженнями ізолюють від PHP.
Що файл може бути одночасно валідною картинкою і містити PHP-код (поліглот), тому «картинка відкрилась» не означає «файл безпечний».
Що SVG, HTML і PDF, віддані з вашого домену, можуть виконати скрипт у контексті сайту, тому їх віддають як вкладення або з окремого домену.
Що розмір обмежують у трьох місцях: `client_max_body_size` у nginx, `upload_max_filesize` і `post_max_size` у PHP, правило `max` у валідації.
Перевіряти розширення в імені або `$_FILES['type']`: `shell.php` з `Content-Type: image/jpeg` пройде таку перевірку.
Зберігати файл під `getClientOriginalName()` у `public/uploads`: імена перетирають одне одного, а розширення `.php` чи `.phtml` в теці, яку обробляє PHP-FPM, дає виконання коду.
Блокувати лише `.php`, забуваючи про `.phtml`, `.phar`, `.php5` і конфігурацію вебсервера, яка може передавати в PHP інші розширення.
Дозволити SVG через `mimes:svg` для аватарів і віддавати їх з основного домену: `<script>` усередині SVG виконається, якщо файл відкрити за прямим посиланням.
Віддавати приватні документи (резюме, паспорти) з `storage/app/public` за передбачуваним шляхом: хто знає або вгадає URL, той і завантажить.
ПОРАДА

Опишіть шлях файлу: прийняли, перевірили вміст, дали своє імʼя, поклали туди, де PHP не виконується, віддаємо через перевірку доступу. На кожному кроці назвіть атаку, яку він закриває, і це буде відповідь рівня Middle+.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`validateMimes()` бере `guessExtension()`, яке спирається на MIME, визначений `finfo` за вмістом. `getClientMimeType()` повертає те, що надіслав клієнт. Валідний JPEG може містити PHP-код у метаданих чи після кінця зображення. `storage/app/public` після `storage:link` віддається вебсервером напряму, без жодної перевірки.