Файл від людини це теж введення
Усе, що описує завантажений файл, у $_FILES пише браузер, тобто будь-хто з curl. Поле name це імʼя на компʼютері клієнта, type це MIME, який він заявив. У Laravel те саме читають getClientOriginalName(), getClientOriginalExtension() і getClientMimeType(): слово Client у назві методу означає, що перевіряти треба вам. Вірити можна лише байтам, які лежать у tmp_name.
Найгірший сценарій виникає, коли файл потрапляє у під своїм розширенням. Вебсервер віддасть avatar.php інтерпретатору PHP, і запит на /uploads/avatar.php виконає код атакувальника на вашому сервері. Laravel за правилом mimes чи extensions відхиляє файл з розширенням php, phtml чи phar, але інші розширення теж небезпечні: avatar.html, віддана як text/html з вашого домену, дає збережену XSS. Тому файли кладуть поза public/ і дають їм нове випадкове імʼя, а розширення ставить сервер за вмістом.
Перевірка вмісту теж має межу. Правило mimes бере MIME з перших байтів файлу через finfo, тож файл, що починається як PNG, буде PNG, навіть якщо далі в ньому HTML чи PHP. Такий пройде і mimes, і dimensions. Надійно прибирає зайве лише перекодування: прочитати пікселі через GD чи Imagick і записати новий файл. Заодно зникають з координатами зйомки й дивні chunk-и. GD метаданих не переносить зовсім, Imagick переносить, поки не викличете stripImage().
Імʼя файлу небезпечне й тоді, коли воно стає шляхом. PHP і Symfony відрізають від імені в $_FILES усе до останньої косої, тож ../ з форми до вас не дійде. Але імʼя з параметра адреси чи з бази, склеєне зі storage_path(), дає : ?file=../../../../.env. Диски Storage кидають PathTraversalDetected, коли шлях виходить за корінь диска, а storage_path() і response()->file() нічого не перевіряють.
Розмір обмежують у двох місцях. php.ini задає upload_max_filesize (за замовчуванням 2M) і post_max_size (8M): більший файл приходить з помилкою й не проходить правило file, а тіло понад post_max_size PHP відкидає, і Laravel відповідає 413. Правило max у Laravel рахує кілобайти: max:2048 це 2 МБ. Воно не піднімає ліміт PHP, тому значення в php.ini мають бути не меншими. І останнє: файл, який хтось може відкрити, віддають з правильним Content-Type, а документи від людей ще й з : attachment, щоб браузер зберіг їх, а не показав як сторінку.
Три кроки від аватара до виконання коду
- Атакувальник створює avatar.php.
- avatar.php використовує Контролер (move()).
- Дані йдуть від Контролер до public/uploads.
- Атакувальник надсилає подію GET, її отримує Вебсервер.
- Вебсервер використовує public/uploads (виконує).
Тип за байтами, імʼя від сервера, тека поза public
-
1
рядки 3–8
name і type з $_FILES приходять від клієнта. Тут вони брешуть обидва: файл зветься avatar.php, а тип заявлено image/png.
-
2
рядки 10–22
Тип визначаємо за першими байтами. У Laravel це робить правило mimes через finfo, тут вручну, щоб було видно, що саме перевіряється.
-
3
рядки 26–28
Без відомої сигнатури або понад ліміт файл не приймаємо. Розмір перевіряє ще й php.ini через upload_max_filesize.
-
4
рядки 30–30
Нове випадкове імʼя, а розширення з вмісту. Імʼя клієнта не потрапляє ні в шлях, ні в URL. У Laravel так працюють store() і hashName().
-
5
рядки 34–36
Поліглот проходить перевірку сигнатури, бо вона справжня. Тому зберігаємо поза public/ і перекодовуємо зображення, а не довіряємо перевірці.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel і Symfony перевіряють файл, крім цих місць
'avatar' => ['required', 'extensions:jpg,png']
Чому це дірка: Правило extensions дивиться лише на розширення в імені від клієнта. Ставте поруч mimes, яке читає вміст, або беріть File::types([...])->extensions([...]).
$file->storeAs('avatars', $file->getClientOriginalName());
Чому це дірка: Розширення вибирає клієнт, а однакові імена перезаписують чужі файли. store() і hashName() дають випадкові 40 символів і розширення за вмістом. Оригінальне імʼя зберігайте в базі, якщо його треба показати.
$request->file('passport')->store('docs', 'public');
Чому це дірка: Диск public лежить у storage/app/public, а storage:link робить його частиною public/. Будь-хто з адресою відкриє файл без входу. Документи кладіть на local (storage/app/private) і віддавайте через маршрут з перевіркою доступу.
'logo' => ['required', 'image:allow_svg']
Чому це дірка: image за замовчуванням не приймає SVG, а allow_svg вмикає. SVG це XML, у ньому може бути <script> чи onload, і відкритий напряму з вашого домену він виконається. Віддавайте такі файли з attachment, очищайте або переводьте в PNG.
response()->download(storage_path('app/'.$request->query('file')));
Чому це дірка: storage_path() склеює рядки, а ../ у параметрі виведе за межі теки. Storage::disk()->download() кидає PathTraversalDetected, але й він віддасть будь-який файл усередині диска. Шукайте файл за id у базі й перевіряйте власника через policy.
upload_max_filesize = 2M
Чому це дірка: Правило max:10240 не підніме ліміт PHP. Файл понад upload_max_filesize приходить з помилкою, і замість вашого повідомлення про розмір людина бачить загальну помилку правила uploaded. post_max_size має бути більшим за upload_max_filesize, бо в тілі ще й інші поля.
return Storage::disk('local')->response($path);
Чому це дірка: response() віддає файл з disposition inline і типом, який визначив Flysystem. HTML чи SVG від людини браузер покаже як сторінку вашого сайту. Для документів беріть download(), він ставить attachment.
$file->getClientOriginalExtension()
Чому це дірка: У Symfony та сама пастка: getClientOriginalExtension() і getClientMimeType() від клієнта, guessExtension() і getMimeType() за вмістом. Для валідації є Assert\File з mimeTypes і extensions.
Де тут дірки в завантаженні
Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.
Вставте у свій шаблон пул-реквесту
- Тип файлу перевіряється за вмістом (mimes, mimetypes, image), а не за getClientMimeType() чи розширенням з імені.
- Імʼя на диску генерує сервер (store, hashName), розширення береться з вмісту, оригінальне імʼя лише в базі.
- Файли лежать поза public/, а приватні документи віддаються через маршрут з перевіркою доступу й Content-Disposition: attachment.
- Шлях до файлу не склеюється з введенням: файл шукається за id у базі, читання йде через Storage.
- Ліміти узгоджені: max у правилі не більший за upload_max_filesize, а post_max_size більший за обидва.
- Зображення від людей перекодовуються або хоча б зменшуються на сервері, SVG не приймається без очищення.
- Feature-тест з UploadedFile::fake() перевіряє, що файл з чужим розширенням і чужим типом відхилено.
Як цей сайт приймає фото профілю
Фото профілю спершу обробляє браузер: resources/js/profile/photo.js читає картинку через createImageBitmap, обрізає до квадрата не більше 512 пікселів і перекодовує canvas у JPEG. Файл важить менше, а EXIF з місцем зйомки на сервер не їде. Сервер на цей скрипт не покладається, бо форму можна відправити й без нього, і перевіряє файл сам: mimes дивиться на вміст, max обмежує 2 МБ, dimensions відсікає крихітні й гігантські картинки.
resources/js/profile/photo.jsОбрізання й перекодування в браузері: canvas.toBlob у JPEG з якістю 0.88. Картинку, яку браузер не прочитав, скрипт відправляє як є, і відповідь дає сервер.app/Http/Controllers/ProfileController.phpРезюме приймається з mimes:pdf, лягає на приватний диск під випадковим імʼям з розширенням .pdf, яке ставить сервер. serveFile віддає його через Storage::download, тобто з attachment, а resumeOf спершу перевіряє, хто питає.app/Http/Middleware/SecurityHeaders.phpВідповіді застосунку несуть X-Content-Type-Options: nosniff, тож браузер не вгадує тип файлу всупереч Content-Type.- Веб-корінь
- Тека, яку вебсервер віддає напряму за адресою, у Laravel це public/. Файл у ній доступний без контролера, а файл з розширенням .php вебсервер передасть PHP на виконання.
- Поліглот
- Файл, який одночасно є коректним файлом двох форматів: наприклад, справжній PNG з HTML чи PHP у текстовому chunk-у. Перевірку за сигнатурою він проходить, бо сигнатура справжня.
- EXIF
- Метадані фото: модель камери, дата, орієнтація, часто GPS-координати місця зйомки. Опублікований аватар з EXIF може показати, де людина живе.
- Path traversal
- Вихід за межі дозволеної теки через ../ у шляху. Код хотів віддати storage/app/docs/report.pdf, а віддає .env з кореня проєкту.
- Content-Disposition
- Заголовок відповіді: inline просить браузер показати файл, attachment зберегти його з указаним filename. HTML чи SVG з attachment не виконаються на вашому домені.
Питання зі співбесід
Це значення з поля type у multipart-запиті, його пише клієнт. curl чи DevTools підставлять image/png до будь-якого файлу. Тип визначають за вмістом: у Laravel правила mimes, mimetypes чи image, вони беруть MIME через finfo.
mimes дивиться на байти, а розширення лишається клієнтське. PNG-поліглот з іменем avatar.html пройде перевірку, ляже в публічну теку, і вебсервер віддасть його як text/html з вашого домену: збережена XSS. Розширення php Laravel блокує, але html, svg, xhtml ні. Імʼя має генерувати сервер, з розширенням за вмістом, як у store().
PHP обрізає раніше за Laravel: upload_max_filesize за замовчуванням 2M, і більший файл приходить з помилкою UPLOAD_ERR_INI_SIZE, тож правило file падає. Тіло понад post_max_size PHP відкидає цілком, і Laravel відповідає 413. Ліміти в php.ini мають бути не меншими за правило, а post_max_size з запасом на інші поля.
Найнадійніше з окремого домену без cookies, як роблять великі сервіси з usercontent-доменами. На тому самому домені: зберігати поза веб-коренем, віддавати через контролер з перевіркою доступу, Content-Type з вмісту, X-Content-Type-Options: nosniff і Content-Disposition: attachment для всього, крім перекодованих зображень. Для зображень, які показуються inline, перекодування на сервері, щоб у файлі лишились тільки пікселі. Плюс CSP sandbox на відповіді з файлами.