ASP.NET MVC 5 中的安全图片上传

MVC 5 示例中的上传示意图:浏览器、路由和 RteController.Upload、Request.Unvalidated.Files、RteFiles 的检查,以及磁盘上的 uploads 文件夹

MVC 5 的上传操作方法接收任何能访问该 URL 的人发来的文件,并把它写入站点的某个文件夹。本页介绍示例中的 RteController.Upload 在写入前检查什么,以及其中哪些检查会被 MVC 模型绑定悄悄跳过。本页还介绍 web.config 如何确保 uploads/ 永远不会运行代码,以及如果部署了 MVC 项目中的哪些文件,IIS 就会把它们提供出去。这里的每个结果都是在 IIS Express 上实测的。

获取文件而不触发 500

C# · Controllers/RteController.cs
[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 });
    }
}

控制器上的 [ValidateInput(false)] 会对 MVC 绑定的值关闭 ASP.NET 请求验证,改由示例自己的规则检查这些值。但它不涵盖 HttpPostedFileBase 类型的操作方法参数。使用 Upload(HttpPostedFileBase file, string folder) 时,名为 <script>x.png 的文件仍会产生 HTML 500 "A potentially dangerous Request.Files value was detected from the client"。这不算漏洞,因为请求被拒绝了。但拒绝结果是一个 HTML 页面,您的错误处理代码根本看不到它。读取 Request.Unvalidated.Files["file"] 能得到同样的 HttpPostedFileBase,然后由下面的检查来决定是否接受。

RteFiles 检查什么

C# · 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。.svg、.php 和 .aspx 无论内容是什么都返回 400。
  • 大小:不超过 4,000,000 字节。更大的文件会得到 JSON 格式的 413。
  • 内容:HttpPostedFileBase.ContentType 只是浏览器声称的类型,以 text/html 提交的 PNG 字节到达服务器时也确实是这个类型。因此要把文件开头的字节与扩展名对应的签名进行比较(JPEG FF D8 FF,PNG 89 50 4E 47 0D 0A 1A 0A,GIF GIF8,WEBP RIFF….WEBP)。
  • 文件名:客户端文件名在最后一个斜杠处手动截断,因为 .NET Framework 的 Path.GetFileName 遇到 < 和 | 会抛出异常。之后文件名只保留 a-z0-9-,并加上 8 个随机十六进制字符。FileMode.CreateNew 绝不会覆盖已有文件。
  • 文件夹:每一段都必须是有效的文件夹名,完整路径必须位于 Server.MapPath("~/uploads") 之内。在契约测试中,folder=../ 得到 400。
C# · RteFiles.cs
// Turns a virtual folder ("", "products", "products/2026") into a directory inside the
// root. Every segment must be a valid folder name, which already rules out "..", ".",
// empty segments, "\", ":", drive letters, NUL and leading "/". The resolved path is
// then checked against the root anyway, so a mistake in the rules cannot escape it.
static string ResolveFolder(StorageRoot root, string folder)
{
    if (folder.Length > 0)
    {
        foreach (string segment in folder.Split('/'))
        {
            if (!IsValidName(segment))
                throw new RteException(400, "Invalid folder.");
        }
    }

    string full = Path.GetFullPath(Path.Combine(root.Dir, folder.Replace('/', '\\')));
    if (!(full.TrimEnd('\\') + "\\").StartsWith(root.Dir, StringComparison.OrdinalIgnoreCase))
        throw new RteException(400, "Invalid folder.");
    if (!Directory.Exists(full))
        throw new RteException(404, "Folder not found.");
    return full;
}

uploads/ 永远不运行代码

签名匹配只能证明文件开头像一张图片。文件夹本身是第二道锁:

XML · web.config
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
     means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
     it - it gets 404, not executed. The extension allow-list also refuses to serve
     anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
  <system.webServer>
    <handlers>
      <clear />
      <add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
    </handlers>
    <security>
      <requestFiltering>
        <fileExtensions allowUnlisted="false">
          <add fileExtension=".jpg" allowed="true" />
          <add fileExtension=".jpeg" allowed="true" />
          <add fileExtension=".png" allowed="true" />
          <add fileExtension=".gif" allowed="true" />
          <add fileExtension=".webp" allowed="true" />
        </fileExtensions>
      </requestFiltering>
    </security>
    <httpProtocol>
      <customHeaders>
        <add name="X-Content-Type-Options" value="nosniff" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</location>

我们在 MVC 示例上做了测试。站点根目录下只有一行 <%= 6*7 %> 的页面返回了 42,说明它被执行了。同一个文件放在 uploads/ 中则返回 404.7(不允许该扩展名)。如果只清空处理程序列表而不设置允许列表,放在那里的 .aspx 会返回 404.3。这一点是在使用相同配置块的 Web Forms 示例上测得的。提供的图片都带有 X-Content-Type-Options: nosniff。MVC 路由不会干扰这一点,因为 /uploads/ 下已存在的文件在尝试任何路由之前就由 IIS 直接提供。

MVC 文件夹还会暴露什么

像 run.ps1 那样直接从项目文件夹运行站点时,我们请求了几个项目文件:

URL响应
/RteFiles.cs、/RteMvc5Sample.csproj404.7(IIS 拒绝这些扩展名)
/web.config、/bin/RteMvc5Sample.dll404.8(隐藏段)
/Views/Home/Index.cshtml404(Views/web.config 中的 BlockViewHandler)
/obj/project.assets.json200,其中包含您的本地路径和包列表

部署 bin\、Views\、Global.asax、web.config 和 rte-files.js,绝不要部署 obj\。同样由于隐藏段规则,示例会拒绝名为 bin 或 App_Data 的图片库文件夹:uploads/bin/ 中的图片返回 404.8。

测试

在端口 5402 上运行本指南的示例项目,然后执行:

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

curl -F "file=@fake.png;filename=x.svg" http://localhost:5402/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

两个响应都是带 JSON 的 HTTP 400。在图片库对话框中,同样的文件会显示对话框自己的 "Upload failed for <name>." 消息,而不是服务器返回的文本。

其他平台的同一指南

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Web Forms

延伸阅读