
Typ MIME i nazwa pliku deklarowane przez przeglądarkę nie są nic warte: odczytaj rzeczywisty typ przez finfo, sprawdź wymiary, przekoduj obraz i wygeneruj nazwę przez random_bytes. Przechowuj poza katalogiem głównym serwera, w katalogu, który niczego nie wykonuje. Przy obrazie w base64 dekoduj w trybie strict i sprawdź rozmiar przed dekodowaniem.
Przyjmowanie pliku wysłanego przez użytkownika to jedna z najbardziej ryzykownych funkcji aplikacji webowej. Wystarczy źle postawiona kontrola i serwer wykona to, co właśnie odebrał. Ten poradnik pokazuje formularz, obsługę wysyłki, a potem zabezpieczenia, które naprawdę się bronią, razem z testami, które dowodzą, które z nich się nie bronią. Omawiamy też wysyłanie obrazów w base64, częste przy javascriptowych narzędziach do kadrowania.
Formularz
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<label for="photo">Photo</label>
<input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">Envoyer</button>
<p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>Atrybut enctype="multipart/form-data" jest obowiązkowy: bez niego przeglądarka wysyła samą nazwę pliku, a $_FILES zostaje pusta. Atrybut accept i pole MAX_FILE_SIZE poprawiają wygodę po stronie przeglądarki, nie są zabezpieczeniem, bo żądanie da się spreparować z pominięciem formularza.
Co zawiera $_FILES
Plik wysłany polem photo trafia do $_FILES['photo'], tablicy asocjacyjnej o pięciu wpisach. Dwa z nich pochodzą od klienta i nigdy nie mogą służyć za kontrolę.
| Klucz | Zawartość | Źródło |
|---|---|---|
name | Oryginalna nazwa pliku wraz z rozszerzeniem | klient |
type | Zadeklarowany typ MIME | klient |
size | Rozmiar odebranego pliku w bajtach | serwer |
tmp_name | Ścieżka pliku tymczasowego na serwerze | serwer |
error | Kod statusu wysyłki, 0 jeśli wszystko poszło dobrze | serwer |
Odebrany plik ląduje w katalogu tymczasowym i znika na koniec działania skryptu. Żeby go zachować, trzeba go przenieść przez move_uploaded_file() albo odczytać przed końcem żądania. Funkcja is_uploaded_file() sprawdza, czy ścieżka rzeczywiście wskazuje na plik pochodzący z wysyłki HTTP, co uniemożliwia skierowanie obsługi na plik systemowy.
Kontrola, która przed niczym nie chroni
Poniższy wzorzec znajdziesz w większości poradników, w tym we wcześniejszych wersjach tego artykułu.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- zadeklarowany przez przeglądarkę
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Dwie dziury, zmierzone na PHP 8.5.10.
Wartość $_FILES['photo']['type'] pochodzi od klienta. PHP nigdy jej nie weryfikuje. Spreparowana wysyłka może zadeklarować cokolwiek:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falsePlik jest skryptem PHP, a kontrola go przepuszcza.
Oryginalna nazwa też pochodzi od klienta. Użycie jej wprost do zbudowania ścieżki otwiera drogę do kilku nadużyć:
'../../config.php' -> extension : php basename : config.php
'photo.php.png' -> extension : png basename : photo.php.png
'photo.png.php' -> extension : php basename : photo.png.php
'photo.pHp' -> extension : pHp basename : photo.pHpPrzypadek photo.php.png jest najbardziej podstępny: rozszerzenie, które widzi pathinfo(), to faktycznie png, więc kontrola przechodzi, ale Apache skonfigurowany z AddHandler na .php może wykonać ten plik przez rozszerzenie pośrednie. Przypadek photo.pHp przypomina z kolei, że porównywanie rozszerzeń jest wrażliwe na wielkość liter.
Walidacja, która się broni
Trzy zasady: typ czytamy z zawartości, a nie z deklaracji, nazwę generujemy od nowa, nigdy jej nie przepisujemy, plik obrazu przekodowujemy.
<?php
declare(strict_types=1);
const TYPES_AUTORISES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* @return array{0: bool, 1: string} sukces i nazwa pliku albo porażka i powód
*/
function validerImage(
string $chemin,
int $maxOctets = 5_242_880,
int $maxPixels = 50_000_000,
): array {
if (!is_file($chemin)) {
return [false, 'fichier absent'];
}
$taille = filesize($chemin);
if ($taille === false || $taille === 0) {
return [false, 'fichier vide'];
}
if ($taille > $maxOctets) {
return [false, 'trop volumineux'];
}
// Rzeczywisty typ, odczytany z bajtów pliku
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
if (!isset(TYPES_AUTORISES[$mime])) {
return [false, "type refusé : {$mime}"];
}
$infos = @getimagesize($chemin);
if ($infos === false) {
return [false, 'image illisible'];
}
[$largeur, $hauteur] = $infos;
if ($largeur < 1 || $hauteur < 1) {
return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
}
if ($largeur * $hauteur > $maxPixels) {
return [false, 'bombe de décompression'];
}
// Przekodowanie: jedyna pewna obrona przed plikami poliglotami
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Nieprzewidywalna nazwa i zapis od razu w katalogu docelowym.
// Przejście przez tempnam(), a potem rename(), zawiodłoby, gdyby /tmp i miejsce
// docelowe leżały na dwóch różnych systemach plików, co jest normą
// w kontenerze.
$nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
$destination = __DIR__ . '/../stockage/' . $nom;
$ok = match (TYPES_AUTORISES[$mime]) {
'jpg' => imagejpeg($image, $destination, 85),
'png' => imagepng($image, $destination),
'gif' => imagegif($image, $destination),
'webp' => imagewebp($image, $destination, 85),
};
if (!$ok) {
return [false, 'réencodage impossible'];
}
return [true, $nom];
}Obsługa żądania, razem z kodami błędów:
<?php
declare(strict_types=1);
require __DIR__ . '/src/upload.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
if (!isset($_FILES['photo'])) {
http_response_code(400);
exit('Aucun fichier reçu');
}
$erreur = $_FILES['photo']['error'];
if ($erreur !== UPLOAD_ERR_OK) {
$message = match ($erreur) {
UPLOAD_ERR_INI_SIZE => 'Fichier plus grand que upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Fichier plus grand que MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Envoi interrompu',
UPLOAD_ERR_NO_FILE => 'Aucun fichier sélectionné',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
UPLOAD_ERR_EXTENSION => 'Envoi bloqué par une extension PHP',
default => 'Erreur inconnue',
};
http_response_code(400);
exit($message);
}
// Gwarantuje, że plik naprawdę pochodzi z wysyłki HTTP
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
http_response_code(400);
exit('Envoi invalide');
}
[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);
if (!$succes) {
http_response_code(422);
exit('Fichier refusé : ' . $resultat);
}
echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');Co wyłapuje każda z kontroli
Cztery pliki przepuszczone przez validerImage(), na PHP 8.5.10 z GD 2.0.35:
| Wysłany plik | Wynik | Uruchomiona kontrola |
|---|---|---|
Skrypt PHP przemianowany na .png | odrzucony | typ rzeczywisty: text/x-php |
| Poliglot: nagłówek GIF + kod PHP | odrzucony | wymiary (0×0 lub absurdalne) |
| Prawdziwy PNG 120×120 | przyjęty | — |
| Prawdziwy PNG z kodem PHP doklejonym na końcu | przyjęty | przekodowany, ładunek znika |
Druga linia zasługuje na chwilę uwagi. Plik zaczynający się od GIF89a, po którym następuje kod PHP, jest rozpoznawany przez finfo jako image/gif. Biblioteka wykrywania ufa sygnaturze pierwszych bajtów, a ta sygnatura jest poprawna. Odrzucają go tylko kontrole wymiarów: zależnie od bajtów po nagłówku getimagesize() odczytuje 0×0 albo absurdalne wymiary i wtedy limit pikseli odrzuca plik. Walidacja kończąca się na finfo go przepuszcza.
Czwarty wiersz to przypadek najczęstszy: całkowicie poprawny obraz, do którego doklejono ładunek. Zostaje przyjęty, i słusznie, bo to prawdziwy obraz, ale zapisany plik jest wynikiem przekodowania, nie oryginałem. Ładunek tego nie przeżywa.
Przechowywanie liczy się tak samo jak walidacja
Żadna walidacja nie zastąpi katalogu, który niczego nie wykonuje. Katalog musi leżeć poza katalogiem głównym serwera i być udostępniany przez skrypt wysyłający właściwy typ zawartości.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}Nagłówek X-Content-Type-Options: nosniff nie pozwala przeglądarce zgadywać typu pliku i wykonać HTML-a albo JavaScriptu znalezionego w obrazie.
Limity z php.ini decydują przed twoim kodem
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}Plik większy niż post_max_size dociera z całkowicie pustymi $_FILES i $_POST: test isset($_FILES['photo']) nie przechodzi, a komunikat błędu wprowadza w błąd. Ten przypadek trzeba wykryć osobno:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
&& (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
http_response_code(413);
exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}Ten fragment ma warunek działania, który odkryliśmy dopiero podczas testów: sprawdza się tylko wtedy, gdy display_errors jest wyłączone. PHP wypisuje własne ostrzeżenie, zanim wykona choćby jedną linię skryptu, a jeśli to ostrzeżenie się pojawi, nagłówki są już wysłane.
display_errors = On -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
of 8388808 bytes exceeds the limit of 6291456 bytes »
puis « Cannot set response code - headers already sent »
display_errors = Off -> HTTP 413 « Envoi plus grand que post_max_size (6M) »Na produkcji display_errors i tak powinno stać na Off. To kolejny powód.
Przy upload_max_filesize = 5M i post_max_size = 6M plik o rozmiarze 5,5 MB przechodzi pierwszą barierę i zatrzymuje się na drugiej, z UPLOAD_ERR_INI_SIZE i kodem 400. post_max_size musi zostać większe niż upload_max_filesize, inaczej ta druga wartość nie ma żadnego znaczenia.
Odbieranie obrazu w base64
Javascriptowe narzędzia do kadrowania i zrzuty z <canvas> często wysyłają obraz jako ciąg data:image/png;base64,…, a nie jako plik. Zasada walidacji się nie zmienia, ale dochodzą dwie pułapki.
Pierwsza: base64_decode() bez drugiego argumentu przyjmuje cokolwiek i zwraca przypadkowe bajty zamiast błędu.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — przypadkowe bajty
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)Druga: ciąg base64 waży mniej więcej o jedną trzecią więcej niż dane, które niesie. Sprawdzenie rozmiaru przed dekodowaniem oszczędza przydzielanie 40 MB po to, żeby zaraz potem odrzucić plik o rozmiarze 30 MB.
<?php
declare(strict_types=1);
/**
* @return array{0: bool, 1: string}
*/
function enregistrerImageBase64(
string $entree,
string $dossier,
int $maxOctets = 2_097_152,
): array {
if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
return [false, 'préfixe data: absent ou type non autorisé'];
}
$encode = substr($entree, strlen($m[0]));
// Base64 waży 4/3 danych: filtrujemy przed dekodowaniem
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // strict
if ($binaire === false) {
return [false, 'base64 invalide'];
}
if (strlen($binaire) > $maxOctets) {
return [false, 'trop volumineux'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => null,
};
if ($extension === null) {
return [false, "type réel refusé : {$mime}"];
}
$infos = @getimagesizefromstring($binaire);
if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
return [false, 'image illisible'];
}
$nom = bin2hex(random_bytes(16)) . '.' . $extension;
if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
return [false, 'écriture impossible'];
}
return [true, $nom];
}Wyniki zmierzone na pięciu wejściach:
| Wejście | Wynik | Powód |
|---|---|---|
| Poprawny PNG 1×1 | przyjęty | — |
Kod PHP zadeklarowany jako image/png | odrzucony | typ rzeczywisty: text/x-php |
SVG z atrybutem onload | odrzucony | typ niedozwolony |
| Dowolny ciąg znaków | odrzucony | brak prefiksu data: |
| Uszkodzony base64 | odrzucony | dekodowanie strict nie powiodło się |
SVG warto wykluczyć świadomie. To format XML, który może nieść JavaScript i odwołania zewnętrzne. Jeśli musisz go przyjmować, przepuść go przez dedykowany sanitizer i nigdy nie serwuj z tej samej domeny co aplikację.
Dwie pułapki kodu, który krąży wszędzie
Naiwne rozcinanie ciągu data: zakłada, że wejście jest poprawnie zbudowane:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1, jeśli wejście jest dowolne
$binaire = base64_decode($parties[1]); // Undefined array key 1, a potem deprecation na nullA nazwa pliku wygenerowana przez uniqid() nie jest nieprzewidywalna: funkcja bierze się z zegara, więc kolejne wartości da się odgadnąć.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) korzysta z kryptograficznego generatora systemu. To jedyna poprawna forma, kiedy nazwa nie może być do odgadnięcia.
Co warto zapamiętać
- Typ i nazwa deklarowane przez klienta nie są nic warte: typ czytaj z zawartości, nazwę generuj od nowa.
finfosamo nie wystarczy, poliglot GIF-a je oszukuje. Dołóż kontrolę wymiarów i przekodowanie.- Przechowuj poza katalogiem głównym serwera, w katalogu, który niczego nie wykonuje.
- Przy base64: dekodowanie strict, kontrola rozmiaru przed dekodowaniem, odrzucenie SVG.
Żeby odesłać nazwę zapisanego pliku do interfejsu bez otwierania luki, zobacz przekazywanie zmiennych z PHP do JavaScriptu. Pliku ważącego kilkadziesiąt megabajtów nie obsłużysz w całości naraz: zobacz czytanie dużych plików w PHP. Po zapisaniu przekierowanie 303 zapobiega ponownemu wysłaniu formularza przy odświeżeniu. A pokazana tu klasa walidacji trzyma się zasad programowania obiektowego w PHP.
Dalej warto zajrzeć do artykułu obróbka obrazów w PHP z GD i Imagickiem oraz do hubu Programowanie webowe.
Częste błędy
image/png dla skryptu PHP. Rzeczywisty typ czyta się przez finfo, nigdy z $_FILES['x']['type'].photo.php.png ma dla pathinfo() faktycznie rozszerzenie png, ale źle skonfigurowany Apache może go wykonać przez rozszerzenie pośrednie. Nazwę generuje się od nowa.GIF89a, po którym idzie kod PHP, jest rozpoznawany jako image/gif. Odrzuca go dopiero kontrola wymiarów.bin2hex(random_bytes(16)).true funkcja przyjmuje cokolwiek i zwraca przypadkowe bajty zamiast false.$_FILES i $_POST docierają puste. A kod wykrywający ten przypadek działa tylko wtedy, gdy display_errors stoi na Off: inaczej PHP wypisze swoje ostrzeżenie przed twoim skryptem i nagłówki będą już wysłane.

