Téléversements de fichiers : guide complet pour développeurs
Un fichier téléversé peut atteindre votre serveur et échouer avant que quoi que ce soit ne soit stocké. Ce guide fournit des gestionnaires locaux de téléversement d’images pour Node.js, Flask et PHP, une intégration Rails authentifiée et une procédure Postman pour distinguer les échecs de requête, de validation et de stockage.
Introduction : qu’est-ce qu’un téléversement de fichier ?
Un navigateur envoie les octets du fichier sélectionné à un point de terminaison récepteur. Avec
multipart/form-data, chaque fichier ou champ de texte constitue une partie distincte portant
un nom de champ. Le récepteur analyse ces parties, applique sa politique d’acceptation et écrit les
octets acceptés dans le stockage. Le nom de fichier ou le type MIME fourni par le client est une
étiquette, pas une preuve du contenu de ces octets.
Configurer une fonctionnalité de téléversement de fichiers de base
Choisissez un gestionnaire et exécutez-en un seul à la fois sur 127.0.0.1:3000.
Ce formulaire HTML envoie un fichier sous le nom de champ myfile :
<form action="http://127.0.0.1:3000/upload" method="post" enctype="multipart/form-data">
<label for="myfile">Image</label>
<input id="myfile" type="file" name="myfile" />
<button type="submit">Upload File</button>
</form>
L’attribut enctype="multipart/form-data" est essentiel, car il indique au navigateur d’envoyer les
données du fichier avec les champs de formulaire standard.
Les programmes Node, Flask et PHP ci-dessous sont des démonstrations locales de gestionnaires de téléversement. Liez-les à l’interface de boucle locale et conservez leur stockage en dehors de toute racine de fichiers statiques ou de documents. Ils n’implémentent pas de connexion : placez devant eux une couche d’authentification, d’autorisation, de protection CSRF, de quotas par utilisateur et de limitation du débit avant le déploiement. Le fragment Rails s’intègre à une application authentifiée existante.
Utilisez Bash ainsi que Node.js et Corepack installés séparément ; consultez le guide d’installation de Yarn. Les versions testées sont Node.js 24.21.0 et Yarn 4.18.0 ; ces versions sont figées pour reproduire l’exemple et ne constituent pas des versions minimales intrinsèques. Travaillez en dehors d’un projet JavaScript existant pour que les paramètres de son gestionnaire de paquets ne puissent pas modifier l’installation. Collez ce bloc dans votre shell ; son sous-shell préserve votre répertoire de travail et les options de votre shell en cas d’échec :
(
for tool in node corepack; do
command -v "$tool" >/dev/null || { printf 'Missing %s\n' "$tool" >&2; exit 1; }
done
[ -z "${YARN_RC_FILENAME:-}" ] || { printf 'Use the default Yarn configuration filename\n' >&2; exit 1; }
node --input-type=module -e '
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
let directory = process.cwd();
for (;;) {
if (["package.json", "yarn.lock", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(directory, name)))) {
throw new Error("Choose a directory outside an existing JavaScript project");
}
const parent = dirname(directory);
if (parent === directory) break;
directory = parent;
}
' &&
mkdir upload-node && cd upload-node &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.0"}' > package.json &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
Le bloc crée upload-node, mais laisse le shell appelant dans son répertoire initial.
Si l’installation échoue, conservez ce répertoire et ses fichiers. Après avoir corrigé la connectivité
ou le problème d’installation signalé, réessayez avec la liste explicite des dépendances ; un simple
yarn install peut réussir avec un manifeste vide après un échec précoce de résolution :
(
cd upload-node &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
Enregistrez le fichier sous upload-node/upload.ts. L’analyseur conserve les données des
fichiers en mémoire, avec jusqu’à deux tampons de fichiers par requête. Le gestionnaire exige une
longueur de corps déclarée, autorise deux réponses HTTP actives et génère les chemins de stockage sur
le serveur. Son compteur HTTP libère une place à la fermeture de la réponse ; il ne limite pas les
écritures asynchrones qui se terminent encore après la déconnexion d’un client.
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import express from 'express'
import fileUpload from 'express-fileupload'
import { fileTypeFromBuffer } from 'file-type'
const app = express()
const maxBytes = 5 * 1024 * 1024
const root = join(process.cwd(), 'private-uploads')
await mkdir(root, { recursive: true, mode: 0o700 })
let active = 0
const parse = fileUpload({
// Busboy signals its limit at the threshold; the extra byte keeps maxBytes inclusive.
limits: { fileSize: maxBytes + 1, files: 2, fields: 0, parts: 3 },
abortOnLimit: true,
useTempFiles: false,
uploadTimeout: 10_000,
})
app.post('/upload', (req, res, next) => {
const length = req.get('content-length') ?? ''
if (!/^[1-9][0-9]*$/.test(length)) return res.status(411).send('Content length required')
if (Number(length) > 6 * 1024 * 1024) return res.status(413).send('Request too large')
if (active >= 2) return res.status(503).send('Busy')
active += 1
res.once('close', () => { active -= 1 })
next()
}, parse, async (req, res) => {
const files = req.files
const file = files?.myfile
if (!files || Object.keys(files).length !== 1 || !file || Array.isArray(file)) {
return res.status(400).send('Send exactly one myfile')
}
if (file.truncated || file.size < 1 || file.size > maxBytes) {
return res.status(413).send('Invalid file size')
}
let type
try {
type = await fileTypeFromBuffer(file.data.subarray(0, 4100))
} catch {
return res.status(400).send('Unrecognized image')
}
const extensions = new Map([['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/gif', 'gif']])
const extension = extensions.get(type?.mime)
if (!extension) return res.status(400).send('Unrecognized image')
const directory = join(root, randomUUID())
let created = false
try {
await mkdir(directory, { mode: 0o700 })
created = true
await writeFile(join(directory, 'image.' + extension), file.data, { flag: 'wx', mode: 0o600 })
if (res.destroyed) {
await rm(directory, { recursive: true, force: true })
return
}
res.status(201).send('File uploaded successfully')
} catch {
if (created) await rm(directory, { recursive: true, force: true })
throw new Error('Storage failed')
}
})
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).send('Upload failed')
})
const server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1', (error) => {
if (error) {
console.error('Upload server could not start')
process.exitCode = 1
}
})
server.requestTimeout = 15_000
server.headersTimeout = 10_000
Démarrez le serveur depuis le même shell appelant que celui où vous avez exécuté la configuration :
(
cd upload-node && corepack yarn node upload.ts
)
Laissez ce terminal ouvert et arrêtez le serveur avec Ctrl+C lorsque vous avez terminé. Enregistrez
le formulaire sous form.html et ouvrez-le dans votre navigateur, ou suivez les
instructions Postman ci-dessous. Chaque requête acceptée renvoie 201 et crée un nouveau répertoire
privé contenant image.jpg, image.png ou
image.gif ; le nom de fichier soumis n’est jamais utilisé comme chemin de stockage.
express-fileupload assure l’analyse multipart ; il n’est pas
intrinsèquement plus sûr qu’un autre analyseur maintenu.
file-type identifie des signatures, pas l’innocuité des fichiers.
Un PNG auquel des octets de script ont été ajoutés peut toujours être identifié comme un PNG.
Le stockage privé et les extensions générées empêchent le gestionnaire de créer un fichier
exécutable sur le Web sous un nom fourni par le client ; la diffusion publique sécurisée d’images
nécessite une politique distincte de décodage, de réencodage et d’analyse de sécurité.
Gérer les téléversements de fichiers dans différents frameworks
Python (Flask)
L’environnement d’exécution Python testé est la version 3.12.3, avec Flask 3.1.3 et Pillow 12.3.0.
Le module venv de Python et pip doivent être installés. Dans un nouveau
répertoire de travail, utilisez un projet distinct et évitez d’activer son venv dans le shell
appelant :
(
command -v python3 >/dev/null || { printf 'Missing python3\n' >&2; exit 1; }
mkdir upload-flask && cd upload-flask &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
Enregistrez le fichier sous upload-flask/app.py. Le gestionnaire complet crée un stockage
privé, vérifie l’image à l’aide d’un véritable analyseur d’images et utilise uniquement une extension
choisie par le serveur. La limite de requête inclut le surcoût multipart ; la limite du fichier est
vérifiée séparément.
from io import BytesIO
from pathlib import Path
import os
import uuid
import warnings
from flask import Flask, abort, request
from PIL import Image, UnidentifiedImageError
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 6 * 1024 * 1024
# Keep Flask's default form-memory limit; Werkzeug also applies it to parser buffers.
app.config['MAX_FORM_PARTS'] = 2
root = Path.cwd() / 'private-uploads'
root.mkdir(mode=0o700, exist_ok=True)
Image.MAX_IMAGE_PIXELS = 20_000_000
extensions = {'JPEG': 'jpg', 'PNG': 'png', 'GIF': 'gif'}
@app.post('/upload')
def upload():
if set(request.files) != {'myfile'} or len(request.files.getlist('myfile')) != 1:
abort(400)
data = request.files['myfile'].stream.read(5 * 1024 * 1024 + 1)
if not data or len(data) > 5 * 1024 * 1024:
abort(413)
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(BytesIO(data), formats=['JPEG', 'PNG', 'GIF']) as image:
extension = extensions.get(image.format)
image.verify()
except (UnidentifiedImageError, OSError, SyntaxError, ValueError,
Image.DecompressionBombError, Image.DecompressionBombWarning):
abort(400)
if extension is None:
abort(400)
destination = root / (uuid.uuid4().hex + '.' + extension)
created = False
try:
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
created = True
with os.fdopen(descriptor, 'wb') as output:
output.write(data)
except OSError:
if created:
destination.unlink(missing_ok=True)
abort(500)
return 'File uploaded successfully.', 201
@app.errorhandler(500)
def storage_error(_error):
return 'Upload failed.', 500
if __name__ == '__main__':
app.run(host='127.0.0.1', port=int(os.environ.get('PORT', '3000')), debug=False)
Démarrez-le depuis le shell appelant initial et arrêtez-le avec Ctrl+C :
(
cd upload-flask && .venv/bin/python app.py
)
Si pip a échoué après la création du venv, conservez-le et réessayez depuis le shell appelant initial :
(
cd upload-flask && .venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
Le serveur intégré est destiné au développement local. Utilisez le même formulaire et le même champ
myfile que pour Node.
La vérification de Pillow
ne réencode pas l’image et ne supprime pas le contenu ajouté à la fin. Stockez les originaux de
manière privée ; isolez le traitement des images et imposez des limites de processeur et de mémoire
avant d’accepter du trafic public non fiable.
PHP
Cet exemple a été testé avec PHP 8.4.26 et son extension FileInfo. Créez d’abord les répertoires :
(
command -v php >/dev/null || { printf 'Missing php\n' >&2; exit 1; }
php -r 'exit(extension_loaded("fileinfo") ? 0 : 1);' &&
mkdir upload-php && cd upload-php &&
mkdir public && mkdir -m 700 private-uploads
)
Enregistrez le gestionnaire sous upload-php/public/upload.php. Démarrez-le depuis le shell appelant
initial et arrêtez-le avec Ctrl+C :
(
cd upload-php &&
php -d display_errors=0 -d upload_max_filesize=5M -d post_max_size=6M -d max_file_uploads=2 -S 127.0.0.1:3000 -t public
)
Définissez l’action du formulaire ou l’URL Postman sur
http://127.0.0.1:3000/upload.php. PHP doit être propriétaire du répertoire privé et pouvoir y écrire.
Le contrat de téléversement de PHP comprend un code d’erreur de
téléversement et un chemin temporaire sur le serveur. Vérifiez-les avant d’inspecter le contenu.
Ne conservez jamais l’extension soumise : un fichier nommé picture.php peut contenir
les octets d’une image.
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
function rejectUpload(int $status): never {
http_response_code($status);
exit('Upload rejected.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405);
}
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 6 * 1024 * 1024) {
rejectUpload(413);
}
$file = $_FILES['myfile'] ?? null;
if (count($_FILES) !== 1 || !is_array($file) ||
!isset($file['error']) || !is_int($file['error'])) {
rejectUpload(400);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) {
rejectUpload(413);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_string($file['tmp_name'] ?? null) ||
!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400);
}
$directory = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false || $size < 1 || $size > 5 * 1024 * 1024) {
rejectUpload(413);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
if (!is_string($mime) || !isset($extensions[$mime])) {
rejectUpload(400);
}
$root = dirname(__DIR__) . '/private-uploads';
if (!is_dir($root) || !is_writable($root)) {
throw new RuntimeException('Storage unavailable');
}
$candidate = $root . '/' . bin2hex(random_bytes(16));
if (!mkdir($candidate, 0700)) {
throw new RuntimeException('Storage unavailable');
}
$directory = $candidate;
$destination = $directory . '/image.' . $extensions[$mime];
if (!move_uploaded_file($file['tmp_name'], $destination) || !chmod($destination, 0600)) {
throw new RuntimeException('Storage unavailable');
}
http_response_code(201);
echo 'File uploaded successfully.';
} catch (Throwable $error) {
if ($directory !== null) {
if (isset($destination) && is_file($destination)) {
unlink($destination);
}
rmdir($directory);
}
error_log('Upload storage failed');
http_response_code(500);
echo 'Upload failed.';
}
Désactivez display_errors pour un déploiement HTTP afin que les avertissements PHP
relatifs au système de fichiers ne puissent pas révéler de chemins. FileInfo détecte le type de
contenu ; ce n’est ni une analyse antimalware ni un décodeur d’images complet. PHP supprime ses fichiers
temporaires de téléversement à la fin de la requête ; le gestionnaire déplace uniquement les fichiers
acceptés vers le stockage privé. Le sous-répertoire privé aléatoire empêche
l’écrasement par move_uploaded_file()
d’autres fichiers téléversés acceptés.
Ruby on Rails
Les validateurs attached, content_type et
size proviennent de
active_storage_validations, et non de Rails lui-même. Cet exemple
utilise Rails 8.1.3.1, l’environnement d’exécution Ruby 3.3.10 testé et la version 4.1.1 de cette gem.
Ajoutez-la au Gemfile de votre application existante et exécutez bundle install :
gem 'active_storage_validations', '4.1.1'
Conservez les dépendances résolues de votre application dans Gemfile.lock.
Utilisez une application authentifiée existante avec Active Storage installé. Configurez un service
Disk privé ou un stockage d’objets privé à l’aide du
guide Active Storage.
Pour un service Disk local, placez sa racine en dehors de public et attribuez
à ce répertoire le mode 0700. L’option de détection de falsification du contenu ci-dessous nécessite
également l’exécutable UNIX file. Les utilitaires testés étaient
file 5.46 et 5.48 ; leur classification des PNG diffère, comme indiqué dans
le tableau d’acceptation ci-dessous. Cet hôte utilisait Rack 3.2.7. Gardez cet utilitaire installé
séparément des gems Ruby.
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :myfile
validates :myfile,
content_type: { in: ['image/png', 'image/jpeg', 'image/gif'], spoofing_protection: true },
size: { between: 1..5.megabytes }
end
Dans une application dont la couche d’authentification fournit déjà current_user,
utilisez ce contrôleur. Ne sélectionnez pas d’utilisateur à partir de params[:user_id].
Il accepte uniquement un nouveau téléversement multipart, pas un identifiant de blob signé
Active Storage fourni par le client.
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
before_action :require_upload_user
protect_from_forgery with: :exception
def new
render :new
end
def create
file = params.require(:user).permit(:myfile)[:myfile]
unless file.is_a?(ActionDispatch::Http::UploadedFile)
return render plain: 'Send a file.', status: :bad_request
end
user = current_user
if user.update(myfile: file)
render plain: 'File uploaded successfully.', status: :created
else
reasons = user.errors.details[:myfile].map { |detail| detail[:error].to_s }.uniq
Rails.logger.warn('Upload validation rejected: ' + reasons.join(', '))
render plain: 'File rejected.', status: :unprocessable_entity
end
end
private
def require_upload_user
head :unauthorized unless current_user
end
end
Ajoutez ces routes à config/routes.rb :
get '/upload', to: 'uploads#new'
post '/upload', to: 'uploads#create'
Enregistrez cette vue sous app/views/uploads/new.html.erb. Rails génère le jeton CSRF et le nom de
champ imbriqué user[myfile] attendu par le contrôleur :
<%= form_with scope: :user, url: '/upload', multipart: true do |form| %>
<%= form.label :myfile, 'Image' %>
<%= form.file_field :myfile, accept: 'image/png,image/jpeg,image/gif', required: true %>
<%= form.submit 'Upload file' %>
<% end %>
Démarrez l’application existante depuis son répertoire de projet :
bin/rails server -b 127.0.0.1 -p 3000
Connectez-vous par son mécanisme de connexion habituel et accédez à /upload.
Arrêtez le serveur local avec Ctrl+C. current_user doit renvoyer
le User connecté ; la fonctionnalité garde les pièces jointes facultatives
pour que la création d’un utilisateur ou la modification de son nom reste possible.
Gardez le service de stockage privé et configurez des contrôleurs de téléchargement authentifiés. Les URL signées par défaut d’Active Storage ne constituent pas une autorisation par utilisateur. Imposez une limite au corps des requêtes sur votre serveur Web avant que Rails n’analyse les données multipart. La validation du modèle intervient après l’analyse et ne peut pas fournir cette limite de ressources réseau. Ces exemples n’activent pas les téléversements directs ; les blobs inutilisés issus d’autres procédures nécessitent une politique de conservation distincte.
Problèmes courants de téléversement de fichiers et solutions
Pourquoi mon fichier ne se téléverse-t-il pas ?
Commencez par la réponse, puis vérifiez quelle étape a refusé la requête. Ces gestionnaires locaux exigent tous un fichier non vide d’au plus 5 MiB (5 242 880 octets), la taille exacte de 5 MiB étant acceptée. Node, Flask et PHP limitent également le corps de requête déclaré à 6 MiB, surcoût multipart compris. Pour Rails, cette limite de corps doit être imposée par votre serveur Web existant avant la validation du modèle.
| Symptôme | Vérifications dans ces exemples |
|---|---|
| 404 ou 405 | Confirmez l’URL et la méthode POST. PHP reçoit /upload.php ; les autres exemples reçoivent /upload. |
| 400 | Vérifiez l’encodage multipart, le champ attendu et la politique de reconnaissance du contenu ci-dessous. Flask renvoie aussi 400 en cas de dépassement de sa limite de pixels ou d’échec de vérification de l’image. L’analyseur de Node peut ne laisser aucun fichier après l’expiration du délai d’inactivité du téléversement, ce qui produit le même code 400 qu’un champ manquant. |
| 411 dans Node | Envoyez une longueur de corps connue. Cette démonstration refuse les requêtes envoyées en flux avec un transfert par blocs. |
| 413 | Vérifiez les limites en octets du fichier et de la requête. Node, Flask et PHP utilisent aussi 413 pour un fichier vide. L’analyseur de Flask peut renvoyer 413 si les parties sont trop nombreuses ou si sa limite de mémoire de formulaire est dépassée. |
| 401 dans Rails | Connectez-vous via l’application hôte. Un téléversement ne peut pas choisir un utilisateur par son identifiant. |
| 422 dans Rails | Fournissez le jeton CSRF de la session et le champ de fichier imbriqué user[myfile]. Une requête CSRF valide peut quand même échouer à la validation du contenu ou de la taille, notamment si le fichier est vide. |
| 500 | Vérifiez les autorisations de stockage. L’hôte Rails testé renvoyait aussi 500 aux limites de fichiers ou de parties de Rack, avant l’exécution du gestionnaire. |
| 503 dans Node | Deux réponses HTTP sont déjà actives. Attendez qu’elles se terminent, puis réessayez. |
Un petit PNG peut dépasser une limite de pixels tout en restant sous la limite en octets. Flask
refuse les images de plus de 20 000 000 pixels ; une image comportant exactement ce nombre de pixels
passe sa vérification des dimensions. Son appel à verify() vérifie la structure
du fichier sans décoder entièrement les pixels. De même, la recherche de signatures de Node,
FileInfo de PHP et la vérification de falsification MIME de Rails ne prouvent pas que chaque image
peut être décodée, que les métadonnées sont supprimées ou qu’un fichier est exempt de logiciels
malveillants.
Les limites de reconnaissance suivantes ont été observées avec les versions de dépendances figées indiquées. « Accepté » signifie que les octets originaux sont stockés de manière privée, et non que leur diffusion publique est approuvée :
| Entrée | Node | Flask | PHP 8.4.26 FileInfo | Rails avec file 5.46 / 5.48 |
|---|---|---|---|---|
| PNG ordinaire, JPEG avec métadonnées, GIF | Accepté | Accepté | Accepté | Accepté / accepté |
| JPEG multi-image MPF/MPO valide | Accepté comme JPEG | 400 : Pillow indique MPO, hors de la liste des formats renvoyés autorisés | Accepté comme JPEG | Accepté / accepté |
| APNG compact | 400 : reconnu comme APNG | Accepté comme PNG | Accepté comme PNG | Accepté / 422 : incompatibilité MIME |
| APNG avec 12 KB de métadonnées avant son marqueur d’animation | Accepté comme PNG | Accepté comme PNG | Accepté comme PNG | Accepté / 422 : incompatibilité MIME |
| APNG avec 40 KB ou 90 KB avant ce marqueur | Accepté comme PNG | Accepté comme PNG | Accepté comme PNG | Accepté / accepté |
Node n’examine que les 4 100 premiers octets. file 5.48 utilise une
règle de signature PNG
qui recherche le marqueur d’animation dans 16 384 octets à partir de l’offset 33. Déplacer un marqueur
au-delà d’une fenêtre de recherche modifie la reconnaissance sans changer le caractère animé de
l’image. Ces gestionnaires n’imposent pas d’interdiction générale des animations. Le refus des MPO
par Flask est aussi une limite de sa politique, pas la preuve d’un JPEG corrompu ; consultez
la prise en charge des MPO par Pillow.
Le nombre de parties multipart présente un autre piège : les champs analysés ne correspondent pas
au nombre total de parties soumises. Node examine au plus deux parties de fichiers et trois parties
au total, ignore les champs de texte et refuse plusieurs fichiers visibles dans sa table de champs
analysés. Un fichier d’un autre champ situé au-delà de la limite de parties de l’analyseur peut être
omis, alors que le fichier visible myfile est stocké avec une réponse 201.
Flask autorise deux parties au total, compte aussi les parties de texte et renvoie 413 au-delà de
cette limite ; les fichiers en double visibles entraînent une réponse 400. Sa limite de mémoire de
formulaire par défaut reste de 500 000 octets, distincte des limites du fichier et du corps.
Le max_file_uploads=2 de PHP indiqué dans l’exemple traite jusqu’à deux parties de fichiers.
Les parties scalaires myfile en double se réduisent à la dernière traitée,
et les parties de fichiers suivantes peuvent être ignorées. De même, Rails prend le dernier
user[myfile] analysé et ignore les autres champs exclus de l’entrée du contrôleur.
Sur l’hôte Rack testé, 127 parties de fichiers et 4 095 parties au total étaient acceptées ; atteindre
128 parties de fichiers ou 4 096 parties au total déclenchait une exception de l’analyseur.
Ces politiques portant sur des champs analysés en nombre limité ne garantissent pas le refus de
chaque doublon brut ou fichier supplémentaire. Envoyez un seul fichier et aucun champ inutile pour
cette tâche.
Solutions
Utilisez d’abord une image connue et décodable, puis modifiez une seule entrée à la fois : le nom du champ, la taille du corps, le contenu ou l’autorisation sur la destination. Node possède en plus un délai d’inactivité du téléversement de dix secondes et un délai d’expiration des requêtes HTTP réglé à 15 secondes. Une requête lente peut échouer sous la limite de taille ; ces paramètres ne garantissent pas un délai exact de réponse mesuré en temps total écoulé.
Pour les échecs de validation Rails, le contrôleur présenté journalise uniquement les codes de motif
issus de errors.details,
sous le préfixe « Upload validation rejected ». Consultez-les après une réponse 422 pour distinguer
les refus liés au type de contenu, à la falsification et à la taille. Ne journalisez pas les octets
soumis, les noms de fichiers, les cookies de session ou les jetons.
Inspectez l’état de Rails avant de réessayer après une erreur de stockage. Un refus de validation conserve la référence et les octets de la pièce jointe précédente. Un échec d’écriture ultérieur est différent : Active Storage enregistre la référence avant le téléversement qui suit le commit de la transaction. Dans le service Disk testé avec l’adaptateur de tâches inline, un échec d’écriture provoqué renvoyait 500 avec une nouvelle référence enregistrée lors du commit de la transaction pointant vers un fichier absent. Le blob précédent et ses octets étaient conservés, mais ne constituaient plus la pièce jointe actuelle de l’utilisateur. Relisez l’utilisateur avec une nouvelle requête et vérifiez le fichier réel de ce blob ou son téléchargement, puis inspectez le blob précédent et votre file de tâches. Une réponse 500 ne prouve pas l’annulation de la transaction ; corrigez la cause liée au stockage et réconciliez la référence avant une nouvelle mise à jour.
Tester les API de téléversement de fichiers avec Postman
Utilisez d’abord la démonstration Node ou Flask en cours d’exécution. Ces instructions ont été testées
dans le client de bureau Linux de Postman 12.31.7 ; PHP utilise le même champ avec son URL
/upload.php. Pour Rails, utilisez le formulaire généré par l’application, sauf si
vous fournissez aussi sa session authentifiée et son jeton CSRF.
- Créez une requête POST vers
http://127.0.0.1:3000/upload. - Ouvrez Body et sélectionnez form-data.
- Ajoutez
myfile, choisissez File comme type et sélectionnez une image locale avec le sélecteur de fichiers du système d’exploitation. - Laissez Postman définir l’en-tête Content-Type multipart pour qu’il inclue le délimiteur correspondant.
- Cliquez sur Send. Vous devriez obtenir 201 et un nouveau fichier stocké ; comparez ensuite ses octets à ceux de l’image sélectionnée.
- Changez uniquement le champ en
wrongfieldet envoyez à nouveau. Node, Flask et PHP renvoient 400 sans ajouter de fichier stocké.
Pour une vérification en ligne de commande, placez le PNG valide choisi à l’emplacement
sample.png dans le répertoire du shell appelant. Utilisez cURL avec la prise en
charge de --fail-with-body ; cette commande a été testée avec cURL 8.5.0 :
curl --fail-with-body --silent --show-error -F "myfile=@./sample.png" http://127.0.0.1:3000/upload
Une réponse réussie indique que le gestionnaire a terminé son écriture. Inspectez aussi
private-uploads : comparez l’intégralité des octets stockés et ouvrez l’image stockée
avec un décodeur indépendant. Une réponse seule ne permet pas de savoir si vous avez inspecté la
bonne sortie. Les démonstrations conservent les originaux acceptés ; arrêtez le serveur et supprimez
uniquement vos propres fichiers de test téléversés lorsque vous avez terminé.
Bonnes pratiques de sécurité pour les téléversements de fichiers
Conservez les originaux en dehors des racines de fichiers statiques ou de documents. Les exemples
Node et PHP utilisent un nouveau sous-répertoire privé nommé par le serveur pour chaque
téléversement ; Flask utilise la création exclusive d’un fichier aléatoire nommé par le serveur.
Les requêtes acceptées répétées conservent des sorties distinctes. Aucun de ces exemples n’utilise
le nom de fichier soumis comme chemin de stockage ; renommer une photo en
photo.php ne crée donc pas de script PHP sous ce nom.
La reconnaissance, le décodage, l’analyse antimalware et la publication sont des décisions distinctes. Un en-tête reconnu peut subsister malgré des dommages dans une partie ultérieure de l’image ; les exemples Node et PHP peuvent stocker un tel fichier. Même la vérification de Pillow n’est pas un décodage complet. Avant la diffusion publique, utilisez une étape de traitement isolée qui décode entièrement le contenu pris en charge, applique des limites de ressources, produit la sortie prévue et suit votre politique d’analyse de sécurité. Gardez l’original privé jusqu’à la fin de ces étapes. Ces gestionnaires locaux n’implémentent ni analyse antimalware ni étape de publication.
Techniques avancées de téléversement de fichiers
Si votre problème réel concerne la reprise de transferts interrompus ou l’affichage de la progression du téléversement, poursuivez avec l’optimisation des téléversements de fichiers dans les applications Web. Ce guide couvre un contrat différent entre le client et la passerelle ; son client ne peut pas être utilisé tel quel avec ces points de terminaison multipart. Pour une intégration Laravel et Vue reposant sur une session, utilisez le guide de téléversement Laravel.
Conclusion et ressources complémentaires
Pour un téléversement multipart ayant échoué, conservez une requête minimale réussie et comparez-la à une requête où une seule condition a changé. Le guide de téléversement Flask, la documentation de téléversement PHP et le guide Active Storage expliquent les contrats natifs sur lesquels reposent ces exemples.
