Envoi d’images sécurisé avec ASP.NET Web Forms
Un dossier d’envoi placé dans un site ASP.NET est un dossier depuis lequel IIS exécutera volontiers du code. Si un fichier .aspx y atterrit un jour, à cause d’un bug, d’un transfert FTP ou d’une modification ultérieure, IIS le compile et l’exécute. Cette page présente les vérifications qu’effectue l’exemple Web Forms avant qu’un seul octet soit écrit, et le bloc web.config qui garde uploads/ statique même si ces vérifications étaient un jour contournées. Nous avons testé chaque couche sous IIS Express.
Avant toute écriture
StorageRoot root = GetRoot(context); // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
throw new RteException(400, "No file was uploaded.");
// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");
string dir = ResolveFolder(root, folder ?? "");
byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
int n = input.Read(data, read, data.Length - read);
if (n <= 0) break;
read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");- Liste d’extensions autorisées : .jpg, .jpeg, .png, .gif, .webp. Un
.svgest refusé, car il peut contenir du script. Un.phpou un.aspxest refusé quels que soient ses octets. - Taille : 4,000,000 octets, avec une réponse 413 en JSON.
- La signature du fichier, et non
ContentType.HttpPostedFile.ContentTypecontient ce qu’a envoyé le navigateur. Nous avons envoyé des octets PNG étiquetéstext/html, et c’est ce qu’il indiquait. L’exemple l’ignore et compare les premiers octets à la signature de l’extension :
static bool SignatureMatches(string ext, byte[] d)
{
switch (ext)
{
case ".jpg":
case ".jpeg":
return d.Length >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF;
case ".png":
return d.Length >= 8 && d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47
&& d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A;
case ".gif":
return d.Length >= 6 && d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38;
case ".webp":
return d.Length >= 12 && Encoding.ASCII.GetString(d, 0, 4) == "RIFF"
&& Encoding.ASCII.GetString(d, 8, 4) == "WEBP";
}
return false;
}Un fichier nommé holiday.jpg qui contient du code source PHP échoue à cette vérification. Un vrai JPEG auquel on a ajouté quelque chose passe quand même. La signature prouve le type, pas l’innocuité du fichier : c’est pourquoi le dossier lui-même est verrouillé, comme décrit plus bas.
Deux détails de .NET Framework se trouvent dans le même code. Le nom de fichier du client n’est jamais passé à Path.GetFileName, qui lève ArgumentException sur < ou | sous .NET Framework 4.8. Le gestionnaire d’envoi lit aussi Request.Unvalidated.Files, car la validation des requêtes couvre les noms de fichiers : un fichier nommé <script>x.png provoquait sinon une erreur HTML 500. Le nom est réduit à a-z0-9- et complété par 8 caractères hexadécimaux aléatoires : rien de ce qu’a choisi le client n’atteint donc le disque.
Où le fichier peut être écrit
Le champ folder passe par le même ResolveFolder que la galerie. Chaque segment doit être un nom de dossier valide, et le résultat de Path.GetFullPath doit rester à l’intérieur de la racine. Dossiers de galerie avec Web Forms présente le code. Dans le test de contrat, un envoi avec folder=../ a reçu un 400, et un dossier inexistant un 404.
uploads/ sert des fichiers et n’exécute rien
IIS choisit ce qu’il exécute selon l’association des gestionnaires, et non selon le dossier. Pour le vérifier, nous avons placé une page d’une ligne, <%= 6*7 %>, à la racine du site sous le nom probe-root.aspx. La demander a renvoyé 42 : elle s’est exécutée. Le web.config de l’exemple l’empêche pour uploads/ :
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
it - it gets 404, not executed. The extension allow-list also refuses to serve
anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
<system.webServer>
<handlers>
<clear />
<add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
</handlers>
<security>
<requestFiltering>
<fileExtensions allowUnlisted="false">
<add fileExtension=".jpg" allowed="true" />
<add fileExtension=".jpeg" allowed="true" />
<add fileExtension=".png" allowed="true" />
<add fileExtension=".gif" allowed="true" />
<add fileExtension=".webp" allowed="true" />
</fileExtensions>
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location><clear />plusStaticFileuniquement. Avant l’ajout de la liste d’extensions, le même.aspxet un.ashxplacés dansuploads/ont répondu 404.3. Il n’y avait aucun gestionnaire pour les exécuter ni aucun type MIME pour les envoyer en texte.fileExtensions allowUnlisted="false". Avec la liste d’autorisation,uploads/probe.aspxet une charge HTML enregistrée sousuploads/probe.txtont tous deux répondu 404.7, tandis que.pnget.webpétaient servis.X-Content-Type-Options: nosniffétait présent sur les images servies : un navigateur ne devinera donc pas un autre type d’après le contenu.
La section handlers est verrouillée par défaut dans l’applicationhost.config d’IIS, mais son bloc <location path="" overrideMode="Allow"> la déverrouille pour les sites, ce qui explique qu’un web.config puisse la modifier. Nous l’avons vérifié dans le modèle d’IIS Express, sans tester un serveur IIS complet. Sur un hébergement qui la verrouille, le site échoue avec une erreur de configuration 500.19 au lieu d’exécuter discrètement des scripts.
Tester
Avec le projet d’exemple de ce guide en cours d’exécution sur le port 5401 :
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5401/rte/upload
{"error":"The file is not a valid PNG image."}
curl -F "file=@fake.png;filename=x.svg" http://localhost:5401/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}Les deux réponses sont des HTTP 400. Pour observer le verrouillage du dossier, copiez n’importe quel .aspx dans uploads\ et demandez-le. Vous obtenez la page 404.7 d’IIS, et non la sortie de la page. Dans la boîte de dialogue de la galerie, un fichier refusé affiche « Upload failed for <name>. ». La boîte de dialogue utilise son propre message plutôt que le texte du serveur.
Le même guide pour d’autres plateformes
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5