Upload pliku w PHP: walidacja, która działa, i obrazy base64

Upload pliku w PHP: walidacja, która działa, i obrazy base64
Szybka odpowiedź

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

public/formulaire.html
<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.

php
$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:

code
type annoncé   : image/png
type réel      : text/x-php
getimagesize() : false

Plik 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ć:

code
'../../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.pHp

Przypadek 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.

src/upload.php
<?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:

upload.php
<?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.

stockage/.htaccess
<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"
nginx.conf
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

php
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:

php
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.

code
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.

php
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.

src/base64.php
<?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:

php
$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 null

A nazwa pliku wygenerowana przez uniqid() nie jest nieprzewidywalna: funkcja bierze się z zegara, więc kolejne wartości da się odgadnąć.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(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.
  • finfo samo 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

Typ MIME z $_FILES pochodzi od klienta Spreparowana wysyłka deklaruje image/png dla skryptu PHP. Rzeczywisty typ czyta się przez finfo, nigdy z $_FILES['x']['type'].
Przepisana oryginalna nazwa 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.
Samo finfo nie wystarczy Plik zaczynający się od GIF89a, po którym idzie kod PHP, jest rozpoznawany jako image/gif. Odrzuca go dopiero kontrola wymiarów.
uniqid() do nazwania pliku Funkcja bierze się z zegara, a jej wartości da się odgadnąć. Użyj bin2hex(random_bytes(16)).
base64_decode() bez trybu strict Bez drugiego argumentu ustawionego na true funkcja przyjmuje cokolwiek i zwraca przypadkowe bajty zamiast false.
Przekroczone post_max_size $_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.
tempnam(), a potem rename() Zawodzi, jeśli katalog tymczasowy i miejsce docelowe leżą na dwóch różnych systemach plików, co w kontenerze jest normą. Zapisuj od razu w miejscu docelowym.
SVG przyjęty jako obraz To XML, który może zawierać JavaScript. Wyklucz go albo sanityzuj i serwuj z innej domeny.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.