Upload images from a rich text editor in ASP.NET Web Forms

A picture inserted into RichTextEditor from the Web Forms sample's gallery, with the full toolbar above it

When someone drops, pastes or picks an image in RichTextEditor, the browser posts the file to your server and waits for the URL to put in the document. On an ASP.NET Web Forms site that endpoint is an IHttpHandler. This page gives you that handler, the web.config lines that put it at /rte/upload, and three .NET Framework details that turn a normal upload into an HTML 500 page if you miss them.

The page

The editor loads from /richtexteditor/. rte-files.js is a short browser file from the sample project. It posts files to /rte/upload as multipart/form-data, with the file in a field named file, and reads {"url": "..."} back. In Default.aspx, ResolveUrl keeps the paths right when the site runs in an IIS virtual directory rather than at the root:

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>

A handler at /rte/upload, without .ashx in the URL

The usual Web Forms answer is a generic handler, upload.ashx. Here it would not work: rte-files.js builds its URL as RTE_FILES_URL + "/upload", so it can never reach a path that ends in .ashx. The handler is instead a plain class in App_Code, and web.config maps the extensionless path to it. No routing code and no Global.asax entry are needed:

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>

A handler type in web.config can name a class in App_Code. It cannot name a class that is defined inside an .ashx file. That is why the class lives in App_Code. The mapping uses preCondition="integratedMode". We did not test the classic pipeline, which uses <system.web><httpHandlers> instead.

The 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);
    }
}

Two lines in it matter more than they look:

  • Request.Unvalidated.Files. ASP.NET request validation covers Request.Files too, and it runs for handlers as well as pages. Before this change, a file named <script>x.png got an HTML 500 page saying "A potentially dangerous Request.Files value was detected from the client". With it, the same request is stored as script-x-xxxxxxxx.png. The file name is only used to build a clean stored name.
  • The HttpException catch. <httpRuntime maxRequestLength> is enforced when the body is first read, which is the line that touches Request.Files. See size limits in Web Forms for when it fires.

What the server checks

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

The allowed extensions are .jpg, .jpeg, .png, .gif and .webp. Files are limited to 4,000,000 bytes, and the first bytes must match the extension. HttpPostedFile.ContentType plays no part. The browser sends it, and when we posted PNG bytes as text/html, that is what ContentType said. The client file name is also cut at the last / or \ by hand. On .NET Framework 4.8, Path.GetFileName("a<b>.png") and Path.GetFileName("a|b.png") throw ArgumentException: Illegal characters in path, so a crafted name would otherwise become a 500.

Where the file goes

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;
    }
}

The stored name is the cleaned base name plus 8 random hex characters, for example harbor-2e576eda.jpg. FileMode.CreateNew means an existing file is never overwritten.

App_Code is compiled on the first request by the framework's own csc.exe. On this machine that was version 4.8.9221.0, which "only supports language versions up to C# 5". The sample's App_Code files use no $"", ?. or => members, so they compile without the Roslyn CodeDom package.

Test it

Run the sample project for this guide with powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. It starts IIS Express at http://localhost:5401/, with no build step. Then:

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

The first answer is HTTP 200 and the second is HTTP 400. In the browser, open Insert Gallery and click Upload. The dialog sends the same request through rte-files.js, then lists the stored file, and Insert puts it into the text. A rejected file shows the dialog's own "Upload failed for <name>." message, not the server's text.

The same guide for other platforms

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

Read next