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

图片库对话框用 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 在两者之间做出选择:
[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 返回。
创建文件夹
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 被视为同一个名称。
文件夹绝不会按原样变成路径
// 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 会丢弃这些字符,导致磁盘上的文件夹与对话框中的名称不一致。
// 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 文件夹,然后执行:
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