ASP.NET Core 中的安全图片上传:关键的检查

示意图:上传从浏览器经过 Kestrel 和 IFormFile 绑定到达 RteFiles.Save,后者在写入磁盘之前检查来源、文件夹、大小、类型和字节内容

上传路由接收任何能访问它的人发来的文件,因此每一种可能出错的情况都需要单独检查。本页按请求经过的顺序,逐一介绍 ASP.NET Core 示例中的检查:请求来自哪里、文件夹、大小、类型、字节内容、保存的文件名,以及之后如何提供文件。每项检查都附有我们故意绕过它时得到的响应。

检查顺序

  1. 来源。浏览器标记为来自其他站点的 POST 会得到 403。
  2. 文件夹。虚拟文件夹必须解析到 uploads/ 内部并且已经存在(否则返回 400 或 404)。ASP.NET Core 中的图片库文件夹介绍了这项检查。
  3. 大小。超过 4,000,000 字节返回 413。大得多的请求会更早被拦截,由ASP.NET Core 中的上传大小限制中介绍的限制处理。
  4. 扩展名。只允许 .jpg、.jpeg、.png、.gif 和 .webp。不允许 SVG,因为从您自己的站点提供的 SVG 可以运行脚本。
  5. 字节内容。文件开头的字节必须与扩展名相符。
  6. 文件名。保存的文件名由服务器生成,因此客户端提供的文件名永远不会写到磁盘上。

不要信任 ContentType 或 FileName

IFormFile.ContentType 是 multipart 中该部分的 Content-Type,由客户端填写。浏览器根据文件名推断它,而 curl 则按指定的值发送。示例忽略这个值,改为读取文件的前十二个字节:

C# · RteFiles.cs
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
    ".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
    ".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
    ".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
    ".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
    _ => false,
};

这能拦住改名为 holiday.jpg 的脚本,但并不能证明文件是有效的图片:文件可以以 PNG 的格式开头,后面却包含别的内容。在这里,让这种文件变得无害的是文件的提供方式(见下文)。如果需要更严格的检查,可以用图片库对每个上传的文件解码再重新编码。示例为了不引入依赖,没有这样做。

C# · RteFiles.cs
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
    var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
    if (name.Length > 40) name = name[..40].TrimEnd('-');
    return name.Length == 0 ? "image" : name;
}

文件的基本名称会转为小写,只保留 a-z、0-9 和 -,再加上由 RandomNumberGenerator 生成的八个随机十六进制字符;文件以 FileMode.CreateNew 打开,遇到同名文件时会失败而不是覆盖。

安全地提供文件

C# · RteEndpoints.cs
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(uploads),
    RequestPath = "/uploads",
    ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
        [".gif"] = "image/gif", [".webp"] = "image/webp",
    }),
    OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});

ASP.NET Core 默认的 FileExtensionContentTypeProvider 识别 .html、.svg 和 .js,所以如果对上传文件夹使用默认的 UseStaticFiles,这些文件会被当作页面和脚本提供。示例中的提供程序只识别五种图片类型。文件夹中的其他文件都会得到 404,因为 ServeUnknownFileTypes 保持关闭。X-Content-Type-Options: nosniff 告诉浏览器使用声明的类型,不要自行猜测。

没有令牌的跨站请求

在 ASP.NET Core 中,防范跨站请求伪造的常规做法是使用防伪造令牌(antiforgery token)。编辑器的上传请求和 New folder 请求是由 rte-files.js 和图片库插件构造的 XHR,当前版本无法为它们添加标头或字段。因此这些路由显式地退出令牌验证,改为检查请求来自哪里:

C# · RteFiles.cs
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
    var site = request.Headers["Sec-Fetch-Site"].ToString();
    var origin = request.Headers.Origin.ToString();
    var foreign = site is "cross-site" or "same-site"
        || (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
            && string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
    if (foreign) throw new RteError(403, "Cross-site request refused.");
}
C# · Controllers/RteController.cs
[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]

浏览器会在这些请求中发送 Sec-Fetch-Site 和 Origin,其他站点上的页面无法更改它们。curl 和服务器端代码两者都不发送,因此会被放行:它们没有用户的 Cookie。如果位于会更改 Host 标头的反向代理之后,请添加 UseForwardedHeaders,使 Request.Host 为公开的主机名。在 Minimal API 版本中,同样的选择对应 .DisableAntiforgery()。在 .NET 8 中这是必需的,原因见在 ASP.NET Core 中从富文本编辑器上传图片。

以上这些都不决定谁可以上传。应用有了登录功能后,请添加 [Authorize] 或 .RequireAuthorization()。

测试

用 dotnet run 运行本指南的示例项目,然后向它发送一个以照片命名的脚本、一个 SVG,以及一个声称来自其他站点的请求:

Shell
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}

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

curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}

curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniff

前两个请求返回 400,第三个返回 403。在图片库中,同一个伪造的 holiday.jpg 也会被拒绝,但对话框显示的是它自己的通用消息,而不是服务器返回的消息:

ASP.NET Core 示例中 blog 文件夹下的 Insert Gallery 对话框,四张照片下方显示消息 Upload failed for holiday.jpg
服务器拒绝了名为 holiday.jpg 的脚本之后的图片库。

其他平台的同一指南

JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms

延伸阅读