在 ASP.NET Web Forms 中从富文本编辑器上传图片

从 Web Forms 示例的图片库插入 RichTextEditor 的一张图片,上方是完整的工具栏

用户在 RichTextEditor 中拖放、粘贴或选择图片时,浏览器会把文件 POST 到服务器,并等待返回要放入文档的 URL。在 ASP.NET Web Forms 站点上,这个端点是一个 IHttpHandler。本页给出这个处理程序、把它映射到 /rte/upload 的 web.config 配置,以及三个 .NET Framework 细节——忽略其中任何一个,正常的上传都会变成一个 HTML 500 页面。

页面

编辑器从 /richtexteditor/ 加载。rte-files.js 是示例项目中一个简短的浏览器端文件。它以 multipart/form-data 格式把文件 POST 到 /rte/upload,文件放在名为 file 的字段中,并读取返回的 {"url": "..."}。在 Default.aspx 中,ResolveUrl 确保站点运行在 IIS 虚拟目录(而不是根目录)中时路径依然正确:

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>

URL 中不带 .ashx 的 /rte/upload 处理程序

Web Forms 的常规做法是使用一般处理程序 upload.ashx。但在这里行不通:rte-files.js 以 RTE_FILES_URL + "/upload" 构造 URL,因此永远无法访问以 .ashx 结尾的路径。所以处理程序改为 App_Code 中的一个普通类,由 web.config 把不带扩展名的路径映射到它。不需要路由代码,也不需要在 Global.asax 中添加任何内容:

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>

web.config 中处理程序的 type 可以指向 App_Code 中的类,但不能指向在 .ashx 文件内定义的类。这就是该类放在 App_Code 中的原因。映射使用了 preCondition="integratedMode"。我们没有测试经典管道,经典管道改用 <system.web><httpHandlers>。

处理程序

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

其中有两行比看上去更重要:

  • Request.Unvalidated.Files。ASP.NET 请求验证同样覆盖 Request.Files,而且对处理程序和页面都会运行。做出这项修改之前,名为 <script>x.png 的文件会得到一个 HTML 500 页面,内容为 "A potentially dangerous Request.Files value was detected from the client"。修改之后,同一个请求会保存为 script-x-xxxxxxxx.png。文件名只用于构造一个干净的保存名称。
  • 对 HttpException 的捕获。<httpRuntime maxRequestLength> 在首次读取请求体时生效,也就是访问 Request.Files 的那一行。它何时触发,见 Web Forms 中的大小限制。

服务器检查什么

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

允许的扩展名为 .jpg、.jpeg、.png、.gif 和 .webp。文件不得超过 4,000,000 字节,并且文件开头的字节必须与扩展名相符。HttpPostedFile.ContentType 不参与判断。它由浏览器发送,我们以 text/html 提交 PNG 字节时,ContentType 显示的就是这个值。客户端文件名同样在最后一个 / 或 \ 处手动截断。在 .NET Framework 4.8 上,Path.GetFileName("a<b>.png") 和 Path.GetFileName("a|b.png") 会抛出 ArgumentException: Illegal characters in path,否则一个精心构造的文件名就会导致 500。

文件存放在哪里

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

保存的文件名是清理后的基本名称加上 8 个随机十六进制字符,例如 harbor-2e576eda.jpg。FileMode.CreateNew 保证绝不覆盖已有文件。

App_Code 在第一次请求时由框架自带的 csc.exe 编译。在我们的机器上,它的版本是 4.8.9221.0,该版本 "only supports language versions up to C# 5"(只支持到 C# 5 的语言版本)。示例的 App_Code 文件没有使用 $""、?. 或 => 成员,因此无需 Roslyn CodeDom 包即可编译。

测试

用 powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder> 运行本指南的示例项目。它会在 http://localhost:5401/ 启动 IIS Express,无需构建步骤。然后执行:

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

第一个响应是 HTTP 200,第二个是 HTTP 400。在浏览器中打开 Insert Gallery 并点击 Upload。对话框会通过 rte-files.js 发送同样的请求,然后列出已保存的文件,点击 Insert 即可把它插入文本。被拒绝的文件会显示对话框自己的 "Upload failed for <name>." 消息,而不是服务器返回的文本。

其他平台的同一指南

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

延伸阅读