Upload images from a rich text editor in ASP.NET MVC 5

RichTextEditor posts each image to your server and inserts the URL that comes back. On ASP.NET MVC 5 that is a route, a controller action and an HttpPostedFileBase. The obvious way to write that action, with the file as a parameter, fails on file names that ASP.NET request validation does not like. This page shows the action that works and the route it needs, and how to build the sample without Visual Studio.
The route
rte-files.js is the browser file from the sample project. It posts to /rte/upload as multipart/form-data, with the file in a field named file and an optional folder, and expects {"url": "..."} back. In Global.asax.cs, two explicit routes come before the default {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" });The view loads the editor and rte-files.js through Url.Content. That way the paths still work when the site runs in an IIS virtual directory:
<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>The 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 });
}
}The file is not an action parameter, and that is deliberate. The controller carries [ValidateInput(false)], because it validates every value itself. But MVC binds an HttpPostedFileBase parameter from the validated Request.Files even under that attribute. With Upload(HttpPostedFileBase file, string folder), a file named <script>x.png got an HTML 500 page saying "A potentially dangerous Request.Files value was detected from the client". That happens before the action runs, so a JSON error is impossible. Request.Unvalidated.Files["file"] returns the same HttpPostedFileBase without that check. With it, the same request is stored as script-x-xxxxxxxx.png.
Errors come back through one helper, so every answer is JSON with the right status:
// 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
};
}What the server checks
The rules live in RteFiles.cs. It uses only HttpContextBase and HttpPostedFileBase, so it works the same from any controller:
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 checks are: an image extension, at most 4,000,000 bytes, and first bytes that match the extension. ContentType is ignored, because the browser chooses it. The client file name is cut at the last slash by hand, because .NET Framework's Path.GetFileName throws on characters like < and |. The file is then written under a random name:
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;
}
}Building it without Visual Studio
The sample is an SDK-style project that targets net48. It writes to bin\, which is where IIS expects the assemblies, and references Microsoft.AspNet.Mvc 5.3.0. It also references Microsoft.NETFramework.ReferenceAssemblies, so no targeting pack is needed. Starting from a folder with no bin or obj, dotnet build restored the packages and copied System.Web.Mvc.dll, Razor and WebPages into bin\, and IIS Express ran the site. Razor views are compiled by ASP.NET at run time, as in any MVC 5 app. The controller and RteFiles.cs also drop into an existing Visual Studio project that uses packages.config.
Test it
Run the sample project for this guide with powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. It runs dotnet build, then starts IIS Express at http://localhost:5402/. Then:
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."}The answers are HTTP 200 and HTTP 400. In the browser, Insert Gallery then Upload sends the same request, and Insert puts the picture into the text. For a rejected file, the dialog shows its own "Upload failed for <name>." rather than the server's message.
The same guide for other platforms
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms