Bilder aus einem Rich-Text-Editor in ASP.NET Web Forms hochladen

Ein Bild, aus der Galerie des Web-Forms-Beispiels in RichTextEditor eingefügt, darüber die vollständige Symbolleiste

Wenn jemand in RichTextEditor ein Bild hineinzieht, einfügt oder auswählt, sendet der Browser die Datei an Ihren Server und wartet auf die URL, die er in das Dokument setzen kann. Auf einer ASP.NET-Web-Forms-Website ist dieser Endpoint ein IHttpHandler. Diese Seite liefert Ihnen diesen Handler, die web.config-Zeilen, die ihn unter /rte/upload bereitstellen, und drei Details von .NET Framework, die aus einem normalen Upload eine HTML-Seite mit 500 machen, wenn Sie sie übersehen.

Die Seite

Der Editor wird aus /richtexteditor/ geladen. rte-files.js ist eine kurze Browserdatei aus dem Beispielprojekt. Sie sendet Dateien als multipart/form-data an /rte/upload, mit der Datei in einem Feld namens file, und liest {"url": "..."} zurück. In Default.aspx sorgt ResolveUrl dafür, dass die Pfade stimmen, wenn die Website in einem virtuellen IIS-Verzeichnis statt im Stammverzeichnis läuft:

ASP.NET · Default.aspx
<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>

Ein Handler unter /rte/upload, ohne .ashx in der URL

Die übliche Antwort in Web Forms ist ein generischer Handler, upload.ashx. Hier würde das nicht funktionieren: rte-files.js bildet seine URL als RTE_FILES_URL + "/upload" und kann daher nie einen Pfad erreichen, der auf .ashx endet. Der Handler ist stattdessen eine einfache Klasse in App_Code, und web.config bildet den Pfad ohne Dateiendung auf sie ab. Es ist weder Routing-Code noch ein Eintrag in Global.asax nötig:

XML · web.config
<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>

Ein Handler-type in web.config kann eine Klasse in App_Code benennen. Er kann keine Klasse benennen, die innerhalb einer .ashx-Datei definiert ist. Deshalb liegt die Klasse in App_Code. Die Zuordnung verwendet preCondition="integratedMode". Die klassische Pipeline, die stattdessen <system.web><httpHandlers> verwendet, haben wir nicht getestet.

Der Handler

C# · App_Code/RteUploadHandler.cs
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);
    }
}

Zwei Zeilen darin sind wichtiger, als sie aussehen:

  • Request.Unvalidated.Files. Die ASP.NET-Anforderungsüberprüfung erfasst auch Request.Files, und sie läuft für Handler ebenso wie für Seiten. Vor dieser Änderung 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“. Mit ihr wird derselbe Request als script-x-xxxxxxxx.png gespeichert. Der Dateiname wird nur verwendet, um einen sauberen gespeicherten Namen zu bilden.
  • Das Abfangen von HttpException. <httpRuntime maxRequestLength> wird beim ersten Lesen des Bodys durchgesetzt, also in der Zeile, die auf Request.Files zugreift. In Größenlimits in Web Forms steht, wann das greift.

Was der Server prüft

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.");

Erlaubt sind die Dateiendungen .jpg, .jpeg, .png, .gif und .webp. Dateien sind auf 4,000,000 Bytes begrenzt, und die ersten Bytes müssen zur Dateiendung passen. HttpPostedFile.ContentType spielt keine Rolle. Der Browser sendet ihn, und als wir PNG-Bytes als text/html gesendet haben, stand genau das in ContentType. Außerdem wird der Dateiname des Clients von Hand am letzten / oder \ abgeschnitten. Unter .NET Framework 4.8 werfen Path.GetFileName("a<b>.png") und Path.GetFileName("a|b.png") eine ArgumentException: Illegal characters in path, ein präparierter Name würde sonst also zu einem 500.

Wohin die Datei geht

C# · App_Code/RteFiles.cs
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;
    }
}

Der gespeicherte Name ist der bereinigte Basisname plus 8 zufällige Hex-Zeichen, zum Beispiel harbor-2e576eda.jpg. FileMode.CreateNew bedeutet, dass eine vorhandene Datei nie überschrieben wird.

App_Code wird beim ersten Request vom framework-eigenen csc.exe kompiliert. Auf diesem Rechner war das Version 4.8.9221.0, die „only supports language versions up to C# 5“. Die App_Code-Dateien des Beispiels verwenden weder $"" noch ?. noch =>-Member und kompilieren daher ohne das Roslyn-CodeDom-Paket.

Testen

Starten Sie das Beispielprojekt zu dieser Anleitung mit powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Das Skript startet IIS Express unter http://localhost:5401/, ohne Build-Schritt. Dann:

Shell
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."}

Die erste Antwort ist HTTP 200, die zweite HTTP 400. Öffnen Sie im Browser Insert Gallery und klicken Sie auf Upload. Der Dialog sendet denselben Request über rte-files.js, listet dann die gespeicherte Datei auf, und Insert fügt sie in den Text ein. Eine abgelehnte Datei zeigt die eigene Meldung des Dialogs „Upload failed for <name>.“, nicht den Text des Servers.

Dieselbe Anleitung für andere Plattformen

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5

Weiterlesen