Téléverser des images depuis un éditeur de texte enrichi avec ASP.NET Web Forms

Quand quelqu’un dépose, colle ou choisit une image dans RichTextEditor, le navigateur envoie le fichier à votre serveur et attend l’URL à placer dans le document. Sur un site ASP.NET Web Forms, ce point de terminaison est un IHttpHandler. Cette page vous donne ce gestionnaire, les lignes de web.config qui le placent sur /rte/upload, et trois détails de .NET Framework qui transforment un envoi normal en page HTML d’erreur 500 si vous les négligez.
La page
L’éditeur est chargé depuis /richtexteditor/. rte-files.js est un court fichier côté navigateur issu du projet d’exemple. Il envoie les fichiers vers /rte/upload en multipart/form-data, avec le fichier dans un champ nommé file, et lit {"url": "..."} en retour. Dans Default.aspx, ResolveUrl garde les chemins corrects quand le site s’exécute dans un répertoire virtuel IIS plutôt qu’à la racine :
<link rel="stylesheet" href="<%= ResolveUrl("~/richtexteditor/rte_theme_default.css") %>" />
<script src="<%= ResolveUrl("~/richtexteditor/rte.js") %>"></script>
<script src="<%= ResolveUrl("~/richtexteditor/plugins/all_plugins.js") %>"></script>
<script>window.RTE_FILES_URL = "<%= ResolveUrl("~/rte") %>";</script>
<script src="<%= ResolveUrl("~/rte-files.js") %>"></script>Un gestionnaire sur /rte/upload, sans .ashx dans l’URL
La réponse habituelle en Web Forms est un gestionnaire générique, upload.ashx. Ici, cela ne fonctionnerait pas : rte-files.js construit son URL sous la forme RTE_FILES_URL + "/upload", et ne peut donc jamais atteindre un chemin qui se termine par .ashx. Le gestionnaire est plutôt une simple classe dans App_Code, et web.config lui associe le chemin sans extension. Aucun code de routage ni aucune entrée dans Global.asax ne sont nécessaires :
<handlers>
<!-- The two endpoints. The classes are in App_Code; ASP.NET compiles them on first use. -->
<add name="RteGallery" path="rte/gallery" verb="*" type="RteGalleryHandler" preCondition="integratedMode" />
<add name="RteUpload" path="rte/upload" verb="*" type="RteUploadHandler" preCondition="integratedMode" />
</handlers>Le type d’un gestionnaire dans web.config peut désigner une classe de App_Code. Il ne peut pas désigner une classe définie dans un fichier .ashx. C’est pourquoi la classe se trouve dans App_Code. L’association utilise preCondition="integratedMode". Nous n’avons pas testé le pipeline classique, qui utilise à la place <system.web><httpHandlers>.
Le gestionnaire
public void ProcessRequest(HttpContext context)
{
try
{
if (context.Request.HttpMethod != "POST")
{
context.Response.AppendHeader("Allow", "POST");
throw new RteException(405, "Use POST.");
}
HttpPostedFile file;
string folder;
try
{
// Reading the body is where ASP.NET enforces <httpRuntime maxRequestLength>.
// Unvalidated: request validation also covers Request.Files, and a file
// named "<b>.png" would otherwise fail with an HTML 500 page.
file = context.Request.Unvalidated.Files["file"];
folder = context.Request.Unvalidated.Form["folder"];
}
catch (HttpException ex)
{
if (ex.WebEventCode != System.Web.Management.WebEventCodes.RuntimeErrorPostTooLarge) throw;
throw new RteException(413, "The upload is larger than the server allows.");
}
string url = RteFiles.SaveUpload(context, file, folder);
RteFiles.WriteJson(context, 200, new { url = url });
}
catch (RteException ex)
{
RteFiles.WriteError(context, ex.Status, ex.Message);
}
}Deux lignes y comptent plus qu’il n’y paraît :
Request.Unvalidated.Files. La validation des requêtes d’ASP.NET couvre aussiRequest.Files, et elle s’applique aux gestionnaires comme aux pages. Avant cette modification, un fichier nommé<script>x.pngrecevait une page HTML d’erreur 500 indiquant « A potentially dangerous Request.Files value was detected from the client ». Avec elle, la même requête est stockée sousscript-x-xxxxxxxx.png. Le nom de fichier ne sert qu’à construire un nom stocké propre.- Le catch de
HttpException.<httpRuntime maxRequestLength>est appliqué à la première lecture du corps, c’est-à-dire à la ligne qui accède àRequest.Files. Voir les limites de taille avec Web Forms pour savoir quand elle se déclenche.
Ce que vérifie le serveur
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.");Les extensions autorisées sont .jpg, .jpeg, .png, .gif et .webp. Les fichiers sont limités à 4,000,000 octets, et les premiers octets doivent correspondre à l’extension. HttpPostedFile.ContentType ne joue aucun rôle. C’est le navigateur qui l’envoie, et quand nous avons envoyé des octets PNG en text/html, c’est ce qu’indiquait ContentType. Le nom de fichier du client est aussi coupé à la main après le dernier / ou \. Sous .NET Framework 4.8, Path.GetFileName("a<b>.png") et Path.GetFileName("a|b.png") lèvent ArgumentException: Illegal characters in path : un nom forgé provoquerait donc sinon une erreur 500.
Où va le fichier
string baseName = SanitizeBaseName(dot >= 0 ? clientName.Substring(0, dot) : clientName);
for (int attempt = 0; ; attempt++)
{
string stored = baseName + "-" + RandomHex(4) + ext;
string path = Path.Combine(dir, stored);
try
{
// CreateNew: never overwrite an existing file, even on a random-name collision.
using (FileStream output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
output.Write(data, 0, data.Length);
return UrlFor(root, folder ?? "", stored);
}
catch (IOException)
{
if (attempt >= 4 || !File.Exists(path)) throw;
}
}Le nom stocké est le nom de base nettoyé suivi de 8 caractères hexadécimaux aléatoires, par exemple harbor-2e576eda.jpg. Avec FileMode.CreateNew, un fichier existant n’est jamais écrasé.
App_Code est compilé à la première requête par le csc.exe propre au framework. Sur cette machine, il s’agissait de la version 4.8.9221.0, qui « only supports language versions up to C# 5 ». Les fichiers App_Code de l’exemple n’utilisent ni $"", ni ?., ni membres => : ils se compilent donc sans le package Roslyn CodeDom.
Tester
Lancez le projet d’exemple de ce guide avec powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Le script démarre IIS Express sur http://localhost:5401/, sans étape de compilation. Ensuite :
curl -F "file=@harbor.jpg" http://localhost:5401/rte/upload
{"url":"/uploads/harbor-2e576eda.jpg"}
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."}La première réponse est un HTTP 200 et la seconde un HTTP 400. Dans le navigateur, ouvrez Insert Gallery et cliquez sur Upload. La boîte de dialogue envoie la même requête via rte-files.js, liste ensuite le fichier stocké, et Insert le place dans le texte. Un fichier refusé affiche 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 · Blazor · MVC 5