Envoi d’images sécurisé avec ASP.NET Web Forms

Schéma d’un envoi dans l’exemple Web Forms : navigateur, association du gestionnaire dans web.config, vérifications de RteFiles, dossier uploads sur le disque et IIS qui le sert uniquement en fichiers statiques

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

C# · App_Code/RteFiles.cs
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 .svg est refusé, car il peut contenir du script. Un .php ou un .aspx est 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.ContentType contient ce qu’a envoyé le navigateur. Nous avons envoyé des octets PNG étiquetés text/html, et c’est ce qu’il indiquait. L’exemple l’ignore et compare les premiers octets à la signature de l’extension :
C# · App_Code/RteFiles.cs
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/ :

XML · web.config
<!-- 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 /> plus StaticFile uniquement. Avant l’ajout de la liste d’extensions, le même .aspx et un .ashx placés dans uploads/ 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.aspx et une charge HTML enregistrée sous uploads/probe.txt ont tous deux répondu 404.7, tandis que .png et .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 :

Shell
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

À lire ensuite