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

MVC 5 示例中 blog 文件夹内的 Insert Gallery 对话框,显示路径 /blog 下的四张图片

图片库对话框用 GET /rte/gallery?folder=blog 打开文件夹,并向同一 URL 发送 POST 来创建文件夹。在 ASP.NET MVC 5 中,这意味着两个同名的操作方法,以及一个名称恰好也是 MVC 保留路由值的表单字段。本页介绍示例如何进行设置,以及来自浏览器的文件夹名称如何变成 uploads/ 内的路径,而又从不被当作可信的路径。

一个 URL,两个操作方法

路由 rte/gallery 指向操作名称 Gallery。GET 方法是 Gallery(string folder),见图片库指南。POST 方法需要另一个 C# 名称,因此用 [ActionName("Gallery")] 把路由名称还给它,再由 [HttpPost] 让 MVC 在两者之间做出选择:

C# · Controllers/RteController.cs
[HttpPost]
[ActionName("Gallery")]
public ActionResult GalleryPost(string folder, string name)
{
    try
    {
        // Read "action" from the form directly: as an action parameter it would collide
        // with MVC's own {action} route value.
        if (Request.Unvalidated.Form["action"] != "create-folder")
            throw new RteException(400, "Unknown action.");
        RteFiles.CreateFolder(HttpContext, folder ?? "", name);
        return JsonStatus(200, RteFiles.Listing(HttpContext, folder ?? ""));
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

名为 "action" 的字段

对话框 POST 的字段是 action=create-folder、folder(父文件夹)和 name。把 string action 作为参数看起来很自然,但 action 也是 MVC 自己的路由值。我们用一个临时的 [HttpPost] Act(string action) 做了测试。不带 action 字段 POST 时,它收到的是 action=Act,也就是路由值。带上该字段时,它收到的是 create-folder。得到的值既取决于表单,也取决于路由。示例改为读取 Request.Unvalidated.Form["action"],这样只有表单字段起作用,缺少该字段时返回 "Unknown action."(400)。

folder 和 name 是普通参数。由于控制器带有 [ValidateInput(false)],它们在绑定时不经过请求验证。没有这个特性时,name=<b>x 会因请求验证而得到 HTML 500。有了它,名称会交给示例自己的规则检查,并以 JSON 400 返回。

创建文件夹

C# · RteFiles.cs
public static void CreateFolder(HttpContextBase context, string parentFolder, string name)
{
    StorageRoot root = GetRoot(context);
    string parentDir = ResolveFolder(root, parentFolder ?? "");
    string problem = NameProblem(name);
    if (problem != null)
        throw new RteException(400, problem);

    string target = Path.Combine(parentDir, name);
    if (Directory.Exists(target) || File.Exists(target))
        throw new RteException(409, "A folder named \"" + name + "\" already exists.");
    Directory.CreateDirectory(target);
}

成功时,操作方法返回父文件夹的列表,因此对话框会立即显示新文件夹。文件夹已存在时返回 409,并且在 NTFS 上,Blog 和 blog 被视为同一个名称。

文件夹绝不会按原样变成路径

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

虚拟文件夹中以 / 分隔的每一部分都必须是有效名称,这本身就排除了 ..、\、: 和空段。拼接后的路径再经过 Path.GetFullPath,并且必须仍以 Server.MapPath("~/uploads") 返回的根目录开头。名称规则比“字母、数字、空格、-、_、.”更严格,原因是我们遇到了以下两个问题:

  • 名为 bin 或 App_Data 的文件夹可以创建,但 IIS 请求筛选会把 URL 中任何位置的这些名称视为隐藏段。放在 uploads/bin/ 中的图片返回 404.8。示例会拒绝这类名称,以及 con 和 aux.txt 之类的 Windows 设备名。
  • 以 . 或空格结尾的名称会被拒绝,因为根据 Microsoft 的文件命名规则,Windows 会丢弃这些字符,导致磁盘上的文件夹与对话框中的名称不一致。
C# · RteFiles.cs
// 1-64 chars of letters, digits, space, '-', '_', '.'; not starting with '.'.
// Windows extras: no trailing '.' or space (Windows silently drops them, so "a." would
// be the same folder as "a"), and no reserved device or IIS-hidden names.
public static bool IsValidName(string name)
{
    return NameProblem(name) == null;
}

static string NameProblem(string name)
{
    const string rule = "Folder names are 1-64 letters, digits, spaces, '-', '_' or '.', and cannot start with '.'.";
    if (string.IsNullOrEmpty(name) || name.Length > 64 || name[0] == '.')
        return rule;
    foreach (char c in name)
    {
        if (!char.IsLetterOrDigit(c) && c != ' ' && c != '-' && c != '_' && c != '.')
            return rule;
    }
    char last = name[name.Length - 1];
    if (last == '.' || last == ' ')
        return "Folder names cannot end with '.' or a space.";
    string lower = name.ToLowerInvariant();
    string stem = lower.Split('.')[0].Trim();
    if (Array.IndexOf(BlockedNames, lower) >= 0 || Array.IndexOf(BlockedNames, stem) >= 0)
        return "\"" + name + "\" is a reserved name on Windows or IIS.";
    return null;
}

测试

在端口 5402 上运行本指南的示例项目,在对话框中创建一个 blog 文件夹,然后执行:

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

curl -F action=create-folder -F folder= -F name=bin http://localhost:5402/rte/gallery
{"error":"\"bin\" is a reserved name on Windows or IIS."}

先是 HTTP 200,列表中出现新文件夹,然后是 HTTP 400。对 /rte/gallery 发送 PUT 会得到 MVC 的普通 404,因为两个操作方法都不接受这个动词。在对话框中,New folder 会要求输入名称,并立即显示新文件夹。

其他平台的同一指南

JavaScript · PHP · ASP.NET Core · Web Forms

延伸阅读