Envoi d’images sécurisé avec ASP.NET MVC 5

Schéma d’un envoi dans l’exemple MVC 5 : navigateur, route et RteController.Upload, Request.Unvalidated.Files, vérifications de RteFiles et dossier uploads sur le disque

Une action d’envoi MVC 5 accepte un fichier de quiconque peut atteindre l’URL et l’écrit dans un dossier de votre site. Cette page passe en revue ce que vérifie le RteController.Upload de l’exemple avant d’écrire, et lesquelles de ces vérifications la liaison de modèle de MVC peut contourner sans rien dire. Elle explique aussi comment web.config empêche uploads/ d’exécuter du code, et quels fichiers d’un projet MVC IIS servirait si vous les déployiez. Tous les résultats présentés ici ont été mesurés sous IIS Express.

Récupérer le fichier sans erreur 500

C# · Controllers/RteController.cs
[HttpPost]
public ActionResult Upload()
{
    try
    {
        // Not action parameters: MVC binds HttpPostedFileBase from the *validated*
        // Request.Files even under [ValidateInput(false)], so a file named "<b>.png"
        // would fail with an HTML 500 before this code runs.
        HttpPostedFileBase file = Request.Unvalidated.Files["file"];
        string folder = Request.Unvalidated.Form["folder"];
        string url = RteFiles.SaveUpload(HttpContext, file, folder);
        return JsonStatus(200, new { url = url });
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

[ValidateInput(false)] sur le contrôleur désactive la validation des requêtes d’ASP.NET pour les valeurs que lie MVC. Les règles propres à l’exemple vérifient ces valeurs à la place. Cela ne couvre pas un paramètre d’action HttpPostedFileBase. Avec Upload(HttpPostedFileBase file, string folder), un fichier nommé <script>x.png a quand même produit une erreur HTML 500 « A potentially dangerous Request.Files value was detected from the client ». Ce n’est pas une faille, puisque la requête a été refusée. Mais ce refus est une page HTML que votre gestion des erreurs ne voit jamais. Lire Request.Unvalidated.Files["file"] donne le même HttpPostedFileBase, et ce sont alors les vérifications ci-dessous qui décident.

Ce que vérifie RteFiles

C# · 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.");
  • Extension : .jpg, .jpeg, .png, .gif ou .webp. .svg, .php et .aspx reçoivent un 400, quel que soit leur contenu.
  • Taille : jusqu’à 4,000,000 octets. Les fichiers plus gros reçoivent un 413 en JSON.
  • Contenu : HttpPostedFileBase.ContentType est ce qu’affirme le navigateur, et des octets PNG envoyés en text/html sont arrivés exactement ainsi. Les premiers octets sont donc comparés à la signature de l’extension (JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, GIF GIF8, WEBP RIFF….WEBP).
  • Nom : le nom de fichier du client est coupé à la main après la dernière barre oblique, car le Path.GetFileName de .NET Framework lève une exception sur < et |. Il est ensuite réduit à a-z0-9-, avec 8 caractères hexadécimaux aléatoires ajoutés. FileMode.CreateNew n’écrase jamais un fichier.
  • Dossier : chaque segment doit être un nom de dossier valide, et le chemin complet doit rester à l’intérieur de Server.MapPath("~/uploads"). folder=../ a reçu un 400 dans le test de contrat.
C# · RteFiles.cs
// Turns a virtual folder ("", "products", "products/2026") into a directory inside the
// root. Every segment must be a valid folder name, which already rules out "..", ".",
// empty segments, "\", ":", drive letters, NUL and leading "/". The resolved path is
// then checked against the root anyway, so a mistake in the rules cannot escape it.
static string ResolveFolder(StorageRoot root, string folder)
{
    if (folder.Length > 0)
    {
        foreach (string segment in folder.Split('/'))
        {
            if (!IsValidName(segment))
                throw new RteException(400, "Invalid folder.");
        }
    }

    string full = Path.GetFullPath(Path.Combine(root.Dir, folder.Replace('/', '\\')));
    if (!(full.TrimEnd('\\') + "\\").StartsWith(root.Dir, StringComparison.OrdinalIgnoreCase))
        throw new RteException(400, "Invalid folder.");
    if (!Directory.Exists(full))
        throw new RteException(404, "Folder not found.");
    return full;
}

uploads/ n’exécute jamais de code

Une signature correcte prouve seulement que le fichier commence comme une image. Le dossier est le second verrou :

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>

Nous l’avons testé sur l’exemple MVC. Une page d’une ligne <%= 6*7 %> à la racine du site a renvoyé 42 : elle s’est donc exécutée. Le même fichier dans uploads/ a répondu 404.7 (extension non autorisée). Avec seulement la liste des gestionnaires vidée et sans liste d’autorisation, un .aspx placé là a répondu 404.3. Nous l’avons mesuré sur l’exemple Web Forms, qui utilise le même bloc. Les images servies portent X-Content-Type-Options: nosniff. Le routage MVC n’interfère pas, car les fichiers existants sous /uploads/ sont servis par IIS avant qu’aucune route ne soit essayée.

Ce qu’un dossier MVC expose d’autre

Avec le site exécuté depuis le dossier du projet, comme le fait run.ps1, nous avons demandé quelques fichiers du projet :

URLRéponse
/RteFiles.cs, /RteMvc5Sample.csproj404.7 (IIS refuse ces extensions)
/web.config, /bin/RteMvc5Sample.dll404.8 (segments masqués)
/Views/Home/Index.cshtml404 (le BlockViewHandler de Views/web.config)
/obj/project.assets.json200, avec vos chemins locaux et la liste de vos packages

Déployez bin\, Views\, Global.asax, web.config et rte-files.js, et jamais obj\. C’est la même règle des segments masqués qui explique pourquoi l’exemple refuse les dossiers de galerie nommés bin ou App_Data : une image placée dans uploads/bin/ a répondu 404.8.

Tester

Avec le projet d’exemple de ce guide en cours d’exécution sur le port 5402 :

Shell
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5402/rte/upload
{"error":"The file is not a valid PNG image."}

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

Les deux réponses sont des HTTP 400 en JSON. Dans la boîte de dialogue de la galerie, les mêmes fichiers produisent le message propre à la boîte de dialogue, « Upload failed for <name>. », et non le texte du serveur.

Le même guide pour d’autres plateformes

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Web Forms

À lire ensuite