Skip to content

Téléversement de fichier (base64 JSON)

NENE2 est un framework JSON-first. Il n'a pas de parsing intégré de multipart/form-data. Le pattern recommandé pour le téléversement de fichiers dans une API JSON est de recevoir les fichiers sous forme de chaînes encodées en base64 dans le corps de la requête JSON.

requestMaxBodyBytes — la première chose à configurer

L'encodage base64 augmente la taille du payload d'environ 33%. Un fichier de 2 Mio devient ~2,7 Mio de caractères base64 avant la surcharge de l'enveloppe JSON.

La valeur par défaut de requestMaxBodyBytes de NENE2 est 1 Mio. Cela signifie que les fichiers plus grands que ~750 Ko seront rejetés avec 413 Request Entity Too Large avant que votre gestionnaire de route soit appelé.

Toujours définir requestMaxBodyBytes à au moins 1,4× votre limite de taille de fichier prévue :

php
new RuntimeApplicationFactory(
    $responseFactory,
    $streamFactory,
    routeRegistrars:     [...],
    requestMaxBodyBytes: 10 * 1024 * 1024,  // permet des fichiers ~7,5 Mio
)
Taille max fichierrequestMaxBodyBytes minimum
500 Ko700 Ko
1 Mio1,5 Mio
2 Mio3 Mio
10 Mio14 Mio

Détection du type MIME — ne jamais faire confiance au client

Toujours détecter le type MIME depuis les octets décodés réels en utilisant finfo_buffer(). Ne jamais se fier à l'en-tête Content-Type fourni par le client ni à l'extension du fichier — les deux peuvent être falsifiés.

php
$bytes = base64_decode($base64Content, strict: true);
if ($bytes === false) {
    // base64 invalide
}

$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->buffer($bytes);

$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mime, $allowed, true)) {
    // rejeter — même si le nom de fichier dit .jpg
}

Cela rejette correctement un script PHP téléversé sous le nom avatar.jpgfinfo lit les octets réels, pas le nom de fichier.

Validation de la taille du fichier

Valider le nombre d'octets décodés, pas la longueur de la chaîne base64 :

php
$size = strlen($bytes);  // octets après base64_decode(), pas strlen($base64String)
if ($size > 2 * 1024 * 1024) {
    // 422 : fichier trop grand
}

Assainissement du nom de fichier — liste de contrôle traversée de chemin

Ne jamais écrire un fichier en utilisant directement un nom de fichier fourni par l'utilisateur. Appliquer tous les points suivants :

php
function sanitizeFilename(string $filename): string
{
    // 1. Supprimer les composants de répertoire (traversée de chemin)
    $name = basename($filename);

    // 2. Supprimer les octets nuls (attaque "image.png\x00.php")
    $name = str_replace("\x00", '', $name);

    // 3. Supprimer les points initiaux (fichiers cachés)
    $name = ltrim($name, '.');

    // 4. Remplacer les caractères non alphanumériques
    $name = preg_replace('/[^\w\-.]/', '_', $name) ?? '_';

    // 5. Neutraliser les extensions de script dangereuses
    $dangerousExts = ['php', 'phtml', 'phar', 'cgi', 'py', 'sh', 'exe'];
    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    if (in_array($ext, $dangerousExts, true)) {
        $name = pathinfo($name, PATHINFO_FILENAME) . '_' . $ext;
    }

    return $name !== '' ? $name : 'file';
}

Toujours préfixer le nom de fichier stocké avec un token aléatoire pour prévenir l'énumération :

php
$stored = bin2hex(random_bytes(8)) . '_' . sanitizeFilename($original);
file_put_contents($storageDir . '/' . $stored, $bytes);

Collision de nom de propriété d'exception PHP

Lors de l'extension d'une classe d'exception intégrée, ne pas déclarer de propriété readonly nommée $code, $message, $file, ou $line — ces propriétés sont non-readonly sur la classe de base et PHP lèvera une erreur fatale :

php
// Erreur fatale : Cannot redeclare non-readonly property Exception::$code
final class UploadException extends \InvalidArgumentException {
    public function __construct(
        public readonly string $code,  // ← conflit avec Exception::$code
    ) {}
}

// Utiliser un nom différent :
final class UploadException extends \InvalidArgumentException {
    public function __construct(
        public readonly string $errorCode,  // ← sûr
    ) {}
}

Exemple complet

php
final class FileValidator
{
    private const array ALLOWED_MIME  = ['image/jpeg', 'image/png', 'image/gif'];
    private const int   MAX_BYTES     = 2 * 1024 * 1024;

    /** @return array{bytes: string, mime: string, size: int} */
    public function validate(string $base64, string $filename): array
    {
        $bytes = base64_decode($base64, strict: true);
        if ($bytes === false) {
            throw new UploadValidationException(field: 'content', errorCode: 'invalid-base64', message: '...');
        }
        $size = strlen($bytes);
        if ($size > self::MAX_BYTES) {
            throw new UploadValidationException(field: 'content', errorCode: 'file-too-large', message: '...');
        }
        $mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
        if (!in_array($mime, self::ALLOWED_MIME, true)) {
            throw new UploadValidationException(field: 'content', errorCode: 'unsupported-mime-type', message: '...');
        }
        return ['bytes' => $bytes, 'mime' => $mime, 'size' => $size];
    }
}

Brancher dans le gestionnaire de route :

php
$body     = JsonRequestBodyParser::parse($request);
$content  = is_string($body['content'] ?? null) ? $body['content'] : '';
$filename = is_string($body['filename'] ?? null) ? $body['filename'] : '';

if ($content === '' || $filename === '') { /* 422 */ }

try {
    $validated = $validator->validate($content, $filename);
} catch (UploadValidationException $e) {
    return $problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
        'errors' => [['field' => $e->field, 'code' => $e->errorCode, 'message' => $e->getMessage()]],
    ]);
}

$stored = bin2hex(random_bytes(8)) . '_' . sanitizeFilename($filename);
file_put_contents('/var/storage/' . $stored, $validated['bytes']);

Publié sous licence MIT.