Bilder aus einem Rich-Text-Editor in ASP.NET MVC 5 hochladen

RichTextEditor sendet jedes Bild an Ihren Server und fügt die zurückgegebene URL ein. In ASP.NET MVC 5 ist das eine Route, eine Controller-Action und ein HttpPostedFileBase. Der naheliegende Weg, diese Action zu schreiben, mit der Datei als Parameter, scheitert an Dateinamen, die der ASP.NET-Anforderungsüberprüfung nicht gefallen. Diese Seite zeigt die Action, die funktioniert, die Route, die sie braucht, und wie Sie das Beispiel ohne Visual Studio bauen.
Die Route
rte-files.js ist die Browserdatei aus dem Beispielprojekt. Sie sendet an /rte/upload als multipart/form-data, mit der Datei in einem Feld namens file und einem optionalen folder, und erwartet {"url": "..."} zurück. In Global.asax.cs stehen zwei explizite Routen vor der Standardroute {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" });Die View lädt den Editor und rte-files.js über Url.Content. So funktionieren die Pfade auch dann noch, wenn die Website in einem virtuellen IIS-Verzeichnis läuft:
<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>Die 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 });
}
}Die Datei ist kein Parameter der Action, und das ist Absicht. Der Controller trägt [ValidateInput(false)], weil er jeden Wert selbst prüft. MVC bindet einen Parameter vom Typ HttpPostedFileBase aber auch unter diesem Attribut aus dem validierten Request.Files. Mit Upload(HttpPostedFileBase file, string folder) erhielt eine Datei namens <script>x.png eine HTML-Seite mit 500 und der Meldung „A potentially dangerous Request.Files value was detected from the client“. Das passiert, bevor die Action läuft, ein JSON-Fehler ist also unmöglich. Request.Unvalidated.Files["file"] gibt dasselbe HttpPostedFileBase ohne diese Prüfung zurück. Damit wird derselbe Request als script-x-xxxxxxxx.png gespeichert.
Fehler kommen über eine einzige Hilfsmethode zurück, sodass jede Antwort JSON mit dem richtigen Status ist:
// 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
};
}Was der Server prüft
Die Regeln stehen in RteFiles.cs. Es verwendet nur HttpContextBase und HttpPostedFileBase und funktioniert daher aus jedem Controller gleich:
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.");Die Prüfungen sind: eine Bild-Dateiendung, höchstens 4,000,000 Bytes und erste Bytes, die zur Dateiendung passen. ContentType wird ignoriert, weil der Browser ihn wählt. Der Dateiname des Clients wird von Hand am letzten Schrägstrich abgeschnitten, weil Path.GetFileName in .NET Framework bei Zeichen wie < und | eine Ausnahme wirft. Die Datei wird dann unter einem zufälligen Namen geschrieben:
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;
}
}Ohne Visual Studio bauen
Das Beispiel ist ein Projekt im SDK-Stil mit dem Ziel net48. Es schreibt nach bin\, wo IIS die Assemblys erwartet, und referenziert Microsoft.AspNet.Mvc 5.3.0. Außerdem referenziert es Microsoft.NETFramework.ReferenceAssemblies, sodass kein Targeting Pack nötig ist. Ausgehend von einem Ordner ohne bin oder obj stellte dotnet build die Pakete wieder her und kopierte System.Web.Mvc.dll, Razor und WebPages nach bin\, und IIS Express führte die Website aus. Razor-Views werden wie in jeder MVC-5-App zur Laufzeit von ASP.NET kompiliert. Der Controller und RteFiles.cs lassen sich auch in ein vorhandenes Visual-Studio-Projekt mit packages.config übernehmen.
Testen
Starten Sie das Beispielprojekt zu dieser Anleitung mit powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Das Skript führt dotnet build aus und startet dann IIS Express unter http://localhost:5402/. Dann:
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."}Die Antworten sind HTTP 200 und HTTP 400. Im Browser sendet Insert Gallery und dann Upload denselben Request, und Insert fügt das Bild in den Text ein. Bei einer abgelehnten Datei zeigt der Dialog seine eigene Meldung „Upload failed for <name>.“ statt der Meldung des Servers.
Dieselbe Anleitung für andere Plattformen
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms