1. Upload comme changement d’état, pas comme échange de données
Lorsqu’un développeur entend pour la première fois « il faut faire un upload d’avatar », le cerveau imagine souvent une image du genre : « c’est comme PATCH, mais au lieu de JSON, c’est un fichier ». C’est une réaction normale, jusqu’à ce que l’on commence à réfléchir à ce qui se passe réellement dans le système. Le téléchargement d’un fichier modifie presque toujours l’état de l’application, et devient donc automatiquement un scénario de sécurité, et pas simplement « une fonctionnalité de plus ».
Dans notre projet, l’avatar fait partie du profil utilisateur. Autrement dit, lorsque nous recevons un fichier, nous ne faisons pas que recevoir des octets « dans le vide ». Nous faisons deux choses : sauvegarder le fichier (ou sa représentation) et modifier le lien vers l’avatar pour un utilisateur donné. Cela ressemble non plus à « transmettre des données », mais à « apporter des modifications à un compte », ce qui est toujours une zone de vigilance accrue.
Un mini-exemple qui illustre bien l’idée : le profil stocke le chemin/lien vers l’avatar, et non le fichier lui-même.
package com.example.securecontent.profile;
public class UserProfile {
private String displayName;
// Important : cela fait partie de l'état du profil, pas un "détail technique"
private String avatarPath;
// getters/setters ...
}
Notez une chose simple mais importante : le champ avatarPath fait partie de l’état de l’utilisateur. Donc POST /api/me/avatar n’est pas une « transmission de fichier », mais une « opération qui modifie le profil ». Par conséquent, nous devons réfléchir à la sécurité au moins aux niveaux : « qui a le droit de le faire », « peut-on falsifier la requête », « que peut-on exactement envoyer » et « que ferons-nous de ce qui nous est envoyé ».
2. Couches de vérifications pour l’endpoint d’upload
Lorsque nous avons un endpoint JSON classique comme PATCH /api/me/profile, nous avons l’habitude : il y a des données d’entrée, une validation, des règles de sécurité. Mais l’upload est une situation où il est trop facile de se tromper si l’on pense uniquement sur le mode « mettons juste authenticated() et c’est bon ». En réalité, un tel endpoint a presque toujours plusieurs couches de vérifications, et elles répondent à des questions différentes.
J’aime envisager un scénario d’upload comme un « couloir avec plusieurs portes ». La première porte est l’accès (authentification/autorisation), la seconde est la validité du contenu, la troisième est la sauvegarde sécurisée du résultat. Si vous placez un gardien seulement à la première porte mais laissez l’entrepôt ouvert avec n’importe quoi à l’intérieur, félicitations, vous avez une entrée surveillée pour un bazar.
Le plus parlant est de le montrer dans un tableau. Ce n’est pas une « norme officielle », mais une carte d’ingénierie pratique :
| Couche de vérification | Question à laquelle on répond | Où cela doit-il vivre | Exemple d’échec |
|---|---|---|---|
| Accès à l’endpoint | « Qui a le droit d’appeler /api/me/avatar ? » | SecurityFilterChain et les règles déjà connues /api/me/** | utilisateur non connecté |
| Protection de la requête modifiant l’état | « La requête n’est-elle pas falsifiée dans le modèle navigateur/cookie ? » | Mécanisme CSRF de Spring Security | pas de token CSRF ou token invalide |
| Vérification du fichier | « Qu’est-ce qui nous a été envoyé exactement et est-ce approprié pour un avatar ? » | logique métier du service (et un peu de configuration) | fichier vide, énorme, pas une image |
| Sauvegarde sécurisée | « Comment sauvegarder sans créer de faille dans le système de fichiers ? » | couche de stockage du projet | path traversal, écrasement du fichier d’un autre |
Le plus important ici est de retenir l’idée : un upload est un scénario où « vérifier l’accès » est nécessaire mais pas suffisant. Chaque couche a sa propre question et son propre point de défaillance.
3. Zone personnelle : /api/me/avatar au lieu de /api/users/{id}/avatar
Si vous avez déjà développé du backend, vous pourriez avoir envie de « généraliser » l’endpoint et d’écrire quelque chose comme POST /api/users/{id}/avatar. Cela semble joli : on passe l’id, on télécharge le fichier, c’est fait. Le problème, du point de vue de la sécurité, est que vous créez soudainement une mine sous vos pieds : qu’est-ce qui empêche un utilisateur ordinaire de soumettre l’id d’un autre ?
Le motif « l’endpoint accepte un identifiant d’objet, mais l’autorisation dans le code est oubliée ou faite de travers » est l’une des sources les plus fréquentes de broken access control. Et il est désagréable car l’application peut « fonctionner à peu près », et la vulnérabilité peut vivre pendant des années, jusqu’à ce que quelqu’un ait l’idée de vérifier « et si je mettais un autre id ».
Dans notre projet, nous avons dès le départ choisi un endpoint de type « owner-by-design » : /api/me/avatar. Il n’accepte pas de userId. Par sa nature, il dit : « je modifie l’avatar de l’utilisateur courant ». Et c’est une très bonne décision pour un projet d’apprentissage : cela ne supprime pas la nécessité de vérifier l’authentification, mais réduit considérablement le risque de créer une vulnérabilité de type « l’utilisateur modifie l’avatar de son voisin ».
Pour sentir la différence, comparons deux contrôleurs. Le premier est potentiellement dangereux (exemple « à ne pas faire ») :
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
public class InsecureAvatarController {
@PostMapping("/api/users/{id}/avatar")
public void upload(@PathVariable long id) {
// Risque : si on oublie/se trompe dans la vérification, on obtiens un IDOR (changement d'avatar pour un autre utilisateur)
// "Oups, où est la vérification que id == current user ?"
}
}
Le second est « owner-by-design », où la requête n’a pas de « levier » sous la forme d’un id étranger :
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
public class MeAvatarController {
@PostMapping("/api/me/avatar")
public void upload() {
// Ici, par conception, il n'y a pas de userId : on modifie l'avatar de l'utilisateur courant seulement
}
}
Notez que nous n’avons pas encore abordé multipart, MultipartFile, types et tailles. Nous avons simplement fait un choix important de conception sécurisée : l’endpoint ne doit pas accidentellement se transformer en « canon universel » qui tire sur n’importe quel utilisateur en cas d’erreur d’autorisation.
4. Risques du chargement multipart au lieu du JSON
Jusqu’à présent, nous avons considéré l’upload comme un scénario de sécurité. Il est maintenant important de voir le symptôme technique : dès qu’un fichier apparaît dans la requête, ce n’est plus un @RequestBody familier avec du JSON, mais du multipart/form-data.
Multipart est une autre bête. Il peut être volumineux, transporter des données binaires, se comporter différemment selon les clients, et a un « coût » physique en ressources : mémoire, disque, temps de traitement. Par conséquent, même un utilisateur correctement authentifié peut toujours vous créer un problème par la taille ou le contenu du fichier.
Et la signature de l’endpoint commence à ressembler à ceci :
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
// consumes est important : on dit explicitement "j'accepte du multipart", pas du JSON
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// file est une entrée utilisateur : il faudra obligatoirement la valider (taille/type/contenu)
// ensuite viendront le service, les vérifications, la sauvegarde...
}
}
Pour l’instant, il suffit de retenir deux choses : l’endpoint déclare explicitement consumes = "multipart/form-data", et le fichier arrive non pas comme un DTO mais comme un MultipartFile. C’est généralement ici que surgit la question d’ingénierie suivante : comment la requête multipart arrive-t-elle jusqu’au contrôleur et à quel moment Spring la transforme-t-elle en MultipartFile.
5. Classes d’erreurs de l’endpoint d’upload
Dans le domaine de la sécurité, il y a une fâcheuse habitude : quand quelque chose ne fonctionne pas, le développeur regarde l’erreur et dit « bon, Spring Security encore une fois ». Puis il fait .csrf(csrf -> csrf.disable()) et se sent vainqueur une minute. Ensuite, la réalité revient généralement et dit : « maintenant explique pourquoi nous avons désactivé la protection contre la falsification de requête ».
Dans un scénario d’upload, il est particulièrement important de comprendre à l’avance que « le téléchargement de l’avatar n’a pas fonctionné » n’est pas du tout une seule erreur. Ce sont au moins quatre classes de problèmes différentes, qui nécessitent des réponses différentes et des niveaux de correction différents.
On peut imaginer cela comme un petit « arbre de décision » :
flowchart TD
A["POST /api/me/avatar"] --> B{"Utilisateur connecté ?"}
B -- non --> C[login flow / accès refusé]
B -- oui --> D{"Token CSRF correct ?"}
D -- non --> E[Échec CSRF]
D -- oui --> F{"Fichier valide ?"}
F -- non --> G[Erreur fichier : vide/type/taille]
F -- oui --> H[Sauvegarder le fichier et mettre à jour le profil]
Notez que seule la dernière branche est une « vraie opération métier ». Tout ce qui précède est de la sécurité et de la validation applicative à la frontière.
Dans le modèle navigateur/session, les « symptômes » peuvent se manifester différemment : dans le navigateur, vous pouvez voir une redirection vers la page de connexion, dans Postman un statut, et dans les logs encore autre chose. C’est normal, nous avons déjà vu que différents clients vivent différemment le même modèle de sécurité.
L’idée clé : nous ne devons pas mélanger dans notre esprit « erreur d’accès », « erreur CSRF » et « erreur de fichier ». Si vous les mélangez, vous allez presque certainement traiter la mauvaise maladie. L’upload est un excellent exercice pour cette compétence, car les erreurs y sont fréquentes et rapides.
6. Structure architecturale dans le projet
Il est maintenant utile de voir non pas une version finale exécutable, mais seulement la structure. Pour le scénario d’upload, il y a ici trois piliers : la zone personnelle /api/me/**, un contrôleur léger sur multipart/form-data, et un service où se rejoindront ensuite les vérifications du fichier, la sauvegarde sécurisée et la mise à jour du profil. Si on essaye de tout fixer d’un coup, la structure se remplit immédiatement de détails et se transforme en une version accidentellement plus faible du même endpoint.
Au niveau des règles d’accès, nous n’avons besoin que d’une seule idée pour l’instant : l’avatar vit dans la zone personnelle, donc l’endpoint hérite de la règle « réservé à l’utilisateur authentifié ».
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/me/**").authenticated()
.anyRequest().denyAll()
);
Les points techniques comme l’obtention du token CSRF ou les détails du flux de connexion ne sont pas encore détaillés ici : ce qui importe, c’est le contour de la zone personnelle.
Le contrôleur est également maintenu sous forme de squelette. Il accepte explicitement du multipart/form-data, attend une partie nommée file, et délègue immédiatement le scénario plus loin.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
private final AvatarService avatarService;
public AvatarController(AvatarService avatarService) {
this.avatarService = avatarService;
}
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// Le contrôleur ne fait qu'accepter les données d'entrée et transmettre le scénario
avatarService.upload(file);
}
}
Et le service peut être perçu pour l’instant comme le centre du scénario, plutôt qu’une implémentation finale.
import org.springframework.web.multipart.MultipartFile;
public class AvatarService {
public void upload(MultipartFile file) {
// Ici se rejoindront plus tard :
// 1) l'utilisateur courant
// 2) les vérifications de taille et de type
// 3) la sauvegarde sécurisée
// 4) la mise à jour d'avatarPath dans le profil
}
}
Cette structure est déjà suffisante pour ne pas perdre la logique : l’endpoint vit dans la zone personnelle, le contrôleur est léger, le travail principal va au service, et la vérification du fichier et le stockage sont des couches séparées, et non des if aléatoires dans une seule méthode.
7. Erreurs typiques lors du chargement d’avatar
Lorsque vous faites un upload de fichier pour la première fois, il est très facile de tomber dans le piège « je connais déjà la sécurité : je mets authenticated() et c’est bon ». En pratique, cela conduit à des solutions fragiles, qui soit créent une faille, soit vous obligent à désactiver des mécanismes de protection parce qu’ils « gênent ». Voici les erreurs les plus fréquentes, au niveau du scénario et de la conception, avant même d’entrer dans les détails du multipart.
Erreur n°1 : considérer l’upload comme une « fonctionnalité ordinaire », et non comme une modification d’état.
Si vous considérez le chargement d’avatar comme une « simple transmission de fichier », il est facile d’oublier que vous modifiez le profil de l’utilisateur et créez un nouvel artefact dans le stockage. Ensuite, il s’avère soudainement que n’importe quel utilisateur connecté peut « envoyer n’importe quoi », et le système ne comprend même pas ce qu’il stocke ni pourquoi. Le bon repère est simple : l’avatar fait partie du profil, donc c’est une opération modifiant l’état et doit être protégée comme une modification de profil.
Erreur n°2 : concevoir l’endpoint comme /api/users/{id}/avatar sans logique propriétaire stricte.
Cette conception n’est pas toujours incorrecte, mais elle nécessite une autorisation très soigneuse : il faut garantir que l’utilisateur ne peut modifier que son propre avatar (ou que l’admin peut modifier celui d’un autre – si c’est explicitement prévu). Si vous n’êtes pas encore prêt à exprimer de telles règles, il est plus sûr et plus simple de garder un endpoint de style /api/me/avatar : moins de chances de faire un « IDOR à la légère ».
Erreur n°3 : mélanger les causes d’erreur en un seul « le chargement n’a pas marché ».
Si vous n’avez pas en tête la distinction « pas connecté », « CSRF non passé », « fichier invalide », « le stockage n’a pas pu sauvegarder », vous allez réparer ailleurs. Par exemple, désactiver CSRF alors que le fichier était simplement vide. Ou commencer à trifouiller le multipart alors que vous n’êtes pas authentifié. Dans un scénario d’upload, il est utile de garder ces causes comme des branches distinctes.
Erreur n°4 : faire du contrôleur le « dieu » du scénario.
Quand dans une seule méthode du contrôleur vous avez à la fois le parsing, la vérification, la sauvegarde, la modification du profil et la gestion des erreurs, le code devient difficile à comprendre et à maintenir. Et dans le code de sécurité, « difficile à maintenir » signifie presque toujours « un jour quelqu’un ouvrira accidentellement l’accès ». Il est beaucoup plus sûr d’avoir une discipline : contrôleur léger, services qui exécutent les étapes, et infrastructure de sécurité qui agit avant l’entrée dans le code métier.
Erreur n°5 : penser que parce que l’utilisateur est connecté, le fichier est automatiquement sûr.
L’authentification répond à la question « qui es-tu ? », mais en aucun cas à la question « qu’as-tu envoyé ? ». Même un utilisateur de bonne foi peut accidentellement télécharger un fichier énorme ou d’un type inapproprié. Et un utilisateur malveillant le fera exprès. C’est pourquoi un scénario d’upload doit toujours avoir une « deuxième couche » de protection : des règles sur le type et la taille, ainsi qu’une sauvegarde prudente.
GO TO FULL VERSION