Envoi d’images sécurisé avec ASP.NET Core : les vérifications qui comptent

Schéma : un envoi passe du navigateur par Kestrel et la liaison IFormFile jusqu’à RteFiles.Save, qui vérifie l’origine, le dossier, la taille, le type et les octets avant l’écriture sur le disque

Une route d’envoi accepte les fichiers de quiconque peut l’atteindre : chaque chose qui peut mal tourner a donc besoin de sa propre vérification. Cette page passe en revue les vérifications de l’exemple ASP.NET Core dans l’ordre où une requête les rencontre : la provenance de la requête, le dossier, la taille, le type, les octets, le nom stocké, et la façon dont le fichier est servi ensuite. Chacune est accompagnée de la réponse que nous avons obtenue en la mettant volontairement en défaut.

Les vérifications, dans l’ordre

  1. Origine. Un POST que le navigateur signale comme provenant d’un autre site reçoit un 403.
  2. Dossier. Le dossier virtuel doit se résoudre à l’intérieur de uploads/ et exister (400 ou 404). Dossiers de galerie avec ASP.NET Core traite de cette vérification.
  3. Taille. Au-delà de 4,000,000 octets, la réponse est 413. Les requêtes beaucoup plus grosses sont arrêtées plus tôt, par les limites décrites dans Limites de taille d’envoi avec ASP.NET Core.
  4. Extension. Uniquement .jpg, .jpeg, .png, .gif et .webp. Le SVG est exclu, car un SVG servi depuis votre propre site peut exécuter du script.
  5. Octets. Les premiers octets doivent correspondre à l’extension.
  6. Nom. Le nom stocké est généré : le nom fourni par le client n’atteint donc jamais le disque.

Ne pas se fier à ContentType ni à FileName

IFormFile.ContentType est le Content-Type de la partie multipart, que le client écrit. Un navigateur le déduit du nom de fichier, et curl envoie ce qu’on lui demande. L’exemple l’ignore et lit plutôt les douze premiers octets :

C# · RteFiles.cs
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
    ".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
    ".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
    ".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
    ".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
    _ => false,
};

Cela arrête un script renommé en holiday.jpg. Cela ne prouve pas que le fichier est une image valide : un fichier peut commencer comme un PNG et contenir autre chose plus loin. Ce qui le rend inoffensif ici, c’est la façon dont le fichier est servi (voir plus bas). S’il vous faut davantage, décodez puis réencodez chaque fichier envoyé avec une bibliothèque d’images. L’exemple ne le fait pas, pour rester sans dépendances.

C# · RteFiles.cs
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
    var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
    if (name.Length > 40) name = name[..40].TrimEnd('-');
    return name.Length == 0 ? "image" : name;
}

Le nom de base est mis en minuscules et réduit à a-z, 0-9 et -, huit caractères hexadécimaux aléatoires issus de RandomNumberGenerator y sont ajoutés, et le fichier est ouvert avec FileMode.CreateNew, qui échoue plutôt que d’écraser.

Servir les fichiers en toute sécurité

C# · RteEndpoints.cs
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(uploads),
    RequestPath = "/uploads",
    ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
        [".gif"] = "image/gif", [".webp"] = "image/webp",
    }),
    OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});

Le FileExtensionContentTypeProvider par défaut d’ASP.NET Core connaît .html, .svg et .js : un UseStaticFiles par défaut sur le dossier d’envoi les servirait donc comme des pages et des scripts. Le fournisseur de l’exemple ne connaît que les cinq types d’images. Tout le reste dans le dossier reçoit un 404, car ServeUnknownFileTypes reste désactivé. X-Content-Type-Options: nosniff indique au navigateur d’utiliser le type déclaré sans chercher à le deviner.

Les requêtes intersites sans jeton

La réponse habituelle d’ASP.NET Core à la falsification de requête intersite est un jeton antifalsification. Les requêtes d’envoi et de New folder de l’éditeur sont des XHR construites par rte-files.js et le plugin de galerie, et la version actuelle ne permet pas d’y ajouter un en-tête ou un champ. Les routes désactivent donc explicitement la validation du jeton et vérifient à la place la provenance de la requête :

C# · RteFiles.cs
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
    var site = request.Headers["Sec-Fetch-Site"].ToString();
    var origin = request.Headers.Origin.ToString();
    var foreign = site is "cross-site" or "same-site"
        || (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
            && string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
    if (foreign) throw new RteError(403, "Cross-site request refused.");
}
C# · Controllers/RteController.cs
[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]

Les navigateurs envoient Sec-Fetch-Site et Origin avec ces requêtes, et une page d’un autre site ne peut pas les modifier. curl et le code côté serveur n’envoient ni l’un ni l’autre, et sont acceptés : ils n’ont pas les cookies de l’utilisateur. Derrière un proxy inverse qui modifie l’en-tête Host, ajoutez UseForwardedHeaders pour que Request.Host soit l’hôte public. Dans la version Minimal API, le même choix s’écrit .DisableAntiforgery(). En .NET 8, il est obligatoire, comme l’explique Téléverser des images depuis un éditeur de texte enrichi avec ASP.NET Core.

Rien de tout cela ne décide qui a le droit d’envoyer des fichiers. Ajoutez [Authorize] ou .RequireAuthorization() dès que votre application dispose d’une connexion.

Tester

Lancez le projet d’exemple de ce guide avec dotnet run et envoyez-lui un script nommé comme une photo, un SVG, et une requête qui prétend venir d’un autre site :

Shell
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}

curl -F "file=@logo.svg" http://localhost:5302/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}

curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniff

Les deux premières réponses sont des 400, la troisième un 403. Dans la galerie, le même faux holiday.jpg est refusé lui aussi, mais la boîte de dialogue affiche son propre message générique, et non celui du serveur :

La boîte de dialogue Insert Gallery dans le dossier blog de l’exemple ASP.NET Core, avec le message Upload failed for holiday.jpg sous quatre photos
La galerie après que le serveur a refusé un script nommé holiday.jpg.

Le même guide pour d’autres plateformes

JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms

À lire ensuite