ASP.NET MVC 5 中的安全图片上传
MVC 5 的上传操作方法接收任何能访问该 URL 的人发来的文件,并把它写入站点的某个文件夹。本页介绍示例中的 RteController.Upload 在写入前检查什么,以及其中哪些检查会被 MVC 模型绑定悄悄跳过。本页还介绍 web.config 如何确保 uploads/ 永远不会运行代码,以及如果部署了 MVC 项目中的哪些文件,IIS 就会把它们提供出去。这里的每个结果都是在 IIS Express 上实测的。
获取文件而不触发 500
[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 检查什么
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 字节到达服务器时也确实是这个类型。因此要把文件开头的字节与扩展名对应的签名进行比较(JPEGFF D8 FF,PNG89 50 4E 47 0D 0A 1A 0A,GIFGIF8,WEBPRIFF….WEBP)。 - 文件名:客户端文件名在最后一个斜杠处手动截断,因为 .NET Framework 的
Path.GetFileName遇到<和|会抛出异常。之后文件名只保留a-z0-9-,并加上 8 个随机十六进制字符。FileMode.CreateNew绝不会覆盖已有文件。 - 文件夹:每一段都必须是有效的文件夹名,完整路径必须位于
Server.MapPath("~/uploads")之内。在契约测试中,folder=../得到 400。
// 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/ 永远不运行代码
签名匹配只能证明文件开头像一张图片。文件夹本身是第二道锁:
<!-- 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.csproj | 404.7(IIS 拒绝这些扩展名) |
/web.config、/bin/RteMvc5Sample.dll | 404.8(隐藏段) |
/Views/Home/Index.cshtml | 404(Views/web.config 中的 BlockViewHandler) |
/obj/project.assets.json | 200,其中包含您的本地路径和包列表 |
部署 bin\、Views\、Global.asax、web.config 和 rte-files.js,绝不要部署 obj\。同样由于隐藏段规则,示例会拒绝名为 bin 或 App_Data 的图片库文件夹:uploads/bin/ 中的图片返回 404.8。
测试
在端口 5402 上运行本指南的示例项目,然后执行:
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