ASP.NET Core 中的图片库文件夹:安全地浏览和创建

ASP.NET Core 示例中 blog 文件夹内的 Insert Gallery 对话框,显示路径 /blog 下的四张照片,第一张处于选中状态

图片库的 New folder 按钮和文件夹图块会把文件夹名称从浏览器发送到服务器。在 ASP.NET Core 中,这个名称必须变成磁盘上的路径,同时绝不能离开 uploads/,而 Windows 对“什么是文件夹名”还有自己的一些意外规则。本页介绍创建文件夹的路由、名称规则和路径检查,并附上我们对错误输入得到的响应。

路由

New folder 以 multipart/form-data 格式把 action=create-folder、当前打开的文件夹和新名称 POST 到图片库 URL:

C# · Controllers/RteController.cs
// POST /rte/gallery   action=create-folder, folder, name
[HttpPost("gallery")]
public IActionResult CreateFolder([FromForm(Name = "action")] string? op, [FromForm] string? folder, [FromForm] string? name) =>
    Send(RteFiles.CreateFolder(HttpContext, op, folder, name));

C# 参数名为 op,用 [FromForm(Name = "action")] 绑定,以免与 MVC 自己的 action 路由值冲突。该方法返回父文件夹的列表,因此对话框能立即显示新文件夹:

C# · RteFiles.cs
// POST /rte/gallery   action=create-folder, folder=<parent>, name=<new folder>
public static RteResult CreateFolder(HttpContext http, string? action, string? folder, string? name) => Run(() =>
{
    RequireSameOrigin(http.Request);
    if (action != "create-folder") throw new RteError(400, "Unknown action.");
    var root = RootFor(http);
    folder ??= "";
    var parent = Resolve(root, folder);
    if (!IsValidName(name))
        throw new RteError(400, "Use 1-64 letters, digits, spaces, '-', '_' or '.', not starting with '.'.");
    var path = Path.Combine(parent, name!);
    if (Directory.Exists(path) || File.Exists(path))
        throw new RteError(409, "A folder with that name already exists.");
    Directory.CreateDirectory(path);
    return Listing(root, folder, parent); // the parent's listing, so the new folder shows
});

在 Windows 上,Directory.Exists 不区分大小写,因此在已有 blog 的位置创建 Blog,会和完全重名一样得到 409。

文件夹名称

C# · RteFiles.cs
// Folder names: 1-64 ASCII letters, digits, space, - _ . ; not starting with "." (no "..", no hidden
// folders). Also refused because Windows mangles them: a trailing "." or space, a leading space,
// and device names such as "con" or "nul.txt".
public static bool IsValidName(string? name) =>
    name is { Length: >= 1 and <= 64 }
    && name.All(c => char.IsAsciiLetterOrDigit(c) || c is ' ' or '-' or '_' or '.')
    && !name.StartsWith('.') && !name.StartsWith(' ')
    && !name.EndsWith('.') && !name.EndsWith(' ')
    && !WindowsDeviceNames.Contains(name.Split('.')[0]);

只允许 ASCII 字母、数字、空格、-、_ 和 .,一步就排除了 /、\、:、% 和 NUL。其他规则源于 Windows 对名称的处理方式:

  • 末尾的点或空格。Path.GetFullPath(@"C:\app\uploads\photos.") 返回 C:\app\uploads\photos。以 photos. 创建的文件夹会被列为 photos,两个名称会指向同一个文件夹。
  • 设备名。con、nul、com1 等名称,无论是否带扩展名,都会被拒绝。name=con 得到 400。
  • 开头的点。这条规则排除了 .. 和 .git 之类的隐藏文件夹。列表也会跳过不符合规则的已有文件夹。

从虚拟文件夹到磁盘路径

浏览器只会发送虚拟路径:""、blog、blog/2026。每个路由都以相同的方式解析它们:

C# · RteFiles.cs
// A virtual folder ("", "products", "products/2026") becomes a disk path inside the root.
// Every segment must be a valid folder name, so "..", "C:", "\", "%2e%2e" and NUL never reach the disk.
static string Resolve(RteRoot root, string folder)
{
    var rootPath = Path.GetFullPath(root.Disk);
    Directory.CreateDirectory(rootPath);
    if (folder.Length == 0) return rootPath;

    var segments = folder.Split('/');
    if (!segments.All(IsValidName)) throw new RteError(400, "Invalid folder.");

    // Second line of defence: the resolved path must still be inside the root. Compare against the
    // root WITH a trailing separator, or "C:\app\uploads-old" would pass as inside "C:\app\uploads".
    var path = Path.GetFullPath(Path.Combine([rootPath, .. segments]));
    if (!path.StartsWith(Path.TrimEndingDirectorySeparator(rootPath) + Path.DirectorySeparatorChar, StringComparison.Ordinal))
        throw new RteError(400, "Invalid folder.");
    if (!Directory.Exists(path)) throw new RteError(404, "Folder not found.");
    return path;
}

每一段都必须符合名称规则,因此 ..、C:、%2e%2e 和开头的 / 在构造任何路径之前就会被拒绝。Path.GetFullPath 之后的包含检查是第二道防线,在 .NET 中它的两个细节都很重要:

  • @"C:\app\uploads-old\x.png".StartsWith(@"C:\app\uploads") 的结果是 true。请像代码中那样,与带末尾分隔符的根目录进行比较。
  • Path.Combine(@"C:\app\uploads", @"C:\Windows") 返回 C:\Windows,Path.Combine(@"C:\app\uploads", @"\Windows") 返回 \Windows。带根的参数会丢弃它之前的所有内容。绝不要把未经检查的请求值传给 Path.Combine。

GetFullPath 会解析 ..,但不会解析目录联接(junction)或符号链接。有服务器访问权限的人放在 uploads/ 中的目录联接仍然可以被浏览。

ASP.NET Core 示例在磁盘上的 uploads 文件夹:blog 中有四张照片,products 和 team 文件夹为空,根目录下有四张照片,文件名都是生成的
图片库的文件夹在磁盘上的样子:uploads/ 下的普通文件夹。

测试

用 dotnet run 运行本指南的示例项目。在图片库中打开 blog 并点击 New folder,或者使用 curl。第一次创建返回 blog 的列表,第二次返回冲突:

Shell
curl -F "action=create-folder" -F "folder=blog" -F "name=2026" http://localhost:5302/rte/gallery
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[{"folder":"blog/2026","name":"2026"}],"images":[...]}

curl -F "action=create-folder" -F "folder=blog" -F "name=2026" http://localhost:5302/rte/gallery
{"error":"A folder with that name already exists."}

curl -F "action=create-folder" -F "folder=" -F "name=.." http://localhost:5302/rte/gallery
{"error":"Use 1-64 letters, digits, spaces, '-', '_' or '.', not starting with '.'."}

curl "http://localhost:5302/rte/gallery?folder=..%2F.."
{"error":"Invalid folder."}

curl "http://localhost:5302/rte/gallery?folder=blog%2Fnope"
{"error":"Folder not found."}

状态码依次为 200、409、400、400 和 404。第一个响应中的图片在这里省略了;对话框用它们来绘制文件夹内容。

其他平台的同一指南

JavaScript · PHP · MVC 5 · Web Forms

延伸阅读