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

RichTextEditor envoie chaque image à votre serveur et insère l’URL qui lui revient. Sous ASP.NET MVC 5, il faut pour cela une route, une action de contrôleur et un HttpPostedFileBase. La façon la plus évidente d’écrire cette action, avec le fichier en paramètre, échoue sur les noms de fichiers que la validation des requêtes d’ASP.NET n’apprécie pas. Cette page présente l’action qui fonctionne et la route dont elle a besoin, ainsi que la compilation de l’exemple sans Visual Studio.
La route
rte-files.js est le fichier côté navigateur du projet d’exemple. Il envoie vers /rte/upload en multipart/form-data, avec le fichier dans un champ nommé file et un champ folder facultatif, et attend {"url": "..."} en retour. Dans Global.asax.cs, deux routes explicites précèdent la route par défaut {controller}/{action}/{id} :
// The two endpoints rte-files.js and the gallery dialog call.
routes.MapRoute("RteGallery", "rte/gallery", new { controller = "Rte", action = "Gallery" });
routes.MapRoute("RteUpload", "rte/upload", new { controller = "Rte", action = "Upload" });La vue charge l’éditeur et rte-files.js via Url.Content. Les chemins restent ainsi valides quand le site s’exécute dans un répertoire virtuel IIS :
<link rel="stylesheet" href="@Url.Content("~/richtexteditor/rte_theme_default.css")" />
<script src="@Url.Content("~/richtexteditor/rte.js")"></script>
<script src="@Url.Content("~/richtexteditor/plugins/all_plugins.js")"></script>
<script>window.RTE_FILES_URL = "@Url.Content("~/rte")";</script>
<script src="@Url.Content("~/rte-files.js")"></script>L’action
[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 });
}
}Le fichier n’est pas un paramètre de l’action, et c’est voulu. Le contrôleur porte [ValidateInput(false)], car il valide lui-même chaque valeur. Mais MVC lie un paramètre HttpPostedFileBase à partir de Request.Files validé, même avec cet attribut. Avec Upload(HttpPostedFileBase file, string folder), un fichier nommé <script>x.png a reçu une page HTML d’erreur 500 indiquant « A potentially dangerous Request.Files value was detected from the client ». Cela se produit avant l’exécution de l’action : une erreur JSON est donc impossible. Request.Unvalidated.Files["file"] renvoie le même HttpPostedFileBase sans cette vérification. Avec lui, la même requête est stockée sous script-x-xxxxxxxx.png.
Les erreurs passent par une seule fonction utilitaire : chaque réponse est donc du JSON avec le bon code d’état.
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
Response.StatusCode = status;
// Without this, IIS replaces 4xx bodies with its own error page for remote users.
Response.TrySkipIisCustomErrors = true;
// Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
Response.SuppressFormsAuthenticationRedirect = true;
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
return new JsonResult
{
Data = data,
JsonRequestBehavior = JsonRequestBehavior.AllowGet,
MaxJsonLength = int.MaxValue
};
}Ce que vérifie le serveur
Les règles se trouvent dans RteFiles.cs. Il n’utilise que HttpContextBase et HttpPostedFileBase et fonctionne donc de la même façon depuis n’importe quel contrôleur :
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 vérifications sont : une extension d’image, 4,000,000 octets au maximum, et des premiers octets qui correspondent à l’extension. ContentType est ignoré, car c’est le navigateur qui le choisit. 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 des caractères comme < et |. Le fichier est ensuite écrit sous un nom aléatoire :
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;
}
}Compiler sans Visual Studio
L’exemple est un projet de style SDK qui cible net48. Il écrit dans bin\, là où IIS attend les assemblys, et référence Microsoft.AspNet.Mvc 5.3.0. Il référence aussi Microsoft.NETFramework.ReferenceAssemblies, si bien qu’aucun pack de ciblage n’est nécessaire. À partir d’un dossier sans bin ni obj, dotnet build a restauré les packages et copié System.Web.Mvc.dll, Razor et WebPages dans bin\, et IIS Express a exécuté le site. Les vues Razor sont compilées par ASP.NET à l’exécution, comme dans toute application MVC 5. Le contrôleur et RteFiles.cs s’intègrent aussi à un projet Visual Studio existant qui utilise packages.config.
Tester
Lancez le projet d’exemple de ce guide avec powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Le script exécute dotnet build, puis démarre IIS Express sur http://localhost:5402/. Ensuite :
curl -F "file=@harbor.jpg" http://localhost:5402/rte/upload
{"url":"/uploads/harbor-fb0799c5.jpg"}
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."}Les réponses sont HTTP 200 et HTTP 400. Dans le navigateur, Insert Gallery puis Upload envoie la même requête, et Insert place l’image dans le texte. Pour un fichier refusé, la boîte de dialogue affiche son propre message « Upload failed for <name>. » plutôt que celui du serveur.
Le même guide pour d’autres plateformes
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms