Galerieordner in ASP.NET MVC 5: sicher durchsuchen und anlegen

Der Dialog „Insert Gallery“ im Ordner blog des MVC-5-Beispiels mit vier Bildern und dem Pfad /blog

Der Galerie-Dialog öffnet Ordner mit GET /rte/gallery?folder=blog und legt sie mit einem POST an dieselbe URL an. In ASP.NET MVC 5 bedeutet das zwei Actions mit einem Namen und ein Formularfeld, dessen Name zugleich ein reservierter Routenwert von MVC ist. Diese Seite zeigt, wie das Beispiel das einrichtet und wie ein Ordnername aus dem Browser zu einem Pfad innerhalb von uploads/ wird, ohne dass ihm jemals als Pfad vertraut wird.

Eine URL, zwei Actions

Die Route rte/gallery zeigt auf den Action-Namen Gallery. Die GET-Methode ist Gallery(string folder), gezeigt in der Galerie-Anleitung. Die POST-Methode braucht einen anderen C#-Namen, daher gibt ihr [ActionName("Gallery")] den Namen der Route zurück, und [HttpPost] lässt MVC zwischen den beiden wählen:

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

Das Feld namens „action“

Der Dialog sendet action=create-folder, folder (den übergeordneten Ordner) und name. string action als Parameter zu nehmen, wirkt naheliegend, aber action ist auch der eigene Routenwert von MVC. Wir haben das mit einer Wegwerf-Action [HttpPost] Act(string action) getestet. Ohne Feld action gesendet, erhielt sie action=Act, den Routenwert. Mit dem Feld erhielt sie create-folder. Welchen Wert Sie bekommen, hängt also von der Route ebenso ab wie vom Formular. Das Beispiel liest stattdessen Request.Unvalidated.Form["action"], sodass nur das Formularfeld zählt, und auf ein fehlendes Feld wird mit „Unknown action.“ (400) geantwortet.

folder und name sind gewöhnliche Parameter. Sie werden ohne Anforderungsüberprüfung gebunden, weil der Controller [ValidateInput(false)] trägt. Ohne das Attribut ergab name=<b>x eine HTML-Seite mit 500 aus der Anforderungsüberprüfung. Mit dem Attribut erreicht der Name die eigenen Regeln des Beispiels und kommt als JSON-400 zurück.

Den Ordner anlegen

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

Bei Erfolg gibt die Action die Liste des übergeordneten Ordners zurück, sodass der Dialog den neuen Ordner sofort anzeigt. Ein vorhandener Ordner ergibt ein 409, und unter NTFS gelten Blog und blog als derselbe Name.

Der Ordner wird nie so, wie er eingegeben wurde, zum Pfad

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

Jeder durch / getrennte Teil des virtuellen Ordners muss ein gültiger Name sein, was .., \, : und leere Teile bereits ausschließt. Der zusammengesetzte Pfad läuft dann durch Path.GetFullPath und muss weiterhin mit dem Stammordner beginnen, den Server.MapPath("~/uploads") zurückgegeben hat. Die Namensregeln sind aus zwei Gründen, auf die wir gestoßen sind, strenger als „Buchstaben, Ziffern, Leerzeichen, -, _, .“:

  • Ein Ordner namens bin oder App_Data lässt sich anlegen, aber die IIS-Anforderungsfilterung behandelt diese Namen an jeder Stelle einer URL als versteckte Segmente. Ein Bild in uploads/bin/ antwortete mit 404.8. Das Beispiel lehnt solche Namen ab, ebenso Windows-Gerätenamen wie con und aux.txt.
  • Namen, die auf . oder ein Leerzeichen enden, werden abgelehnt, weil Windows laut den Microsoft-Regeln für Dateinamen diese Zeichen entfernt und der Ordner auf der Festplatte dann nicht zum Namen im Dialog passen würde.
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;
}

Testen

Während das Beispielprojekt zu dieser Anleitung auf Port 5402 läuft, legen Sie im Dialog einen Ordner blog an, dann:

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 mit dem neuen Ordner in der Liste, dann HTTP 400. Ein PUT an /rte/gallery erhält das gewöhnliche 404 von MVC, weil keine der beiden Actions dieses Verb akzeptiert. Im Dialog fragt New folder nach einem Namen und zeigt den Ordner sofort an.

Dieselbe Anleitung für andere Plattformen

JavaScript · PHP · ASP.NET Core · Web Forms

Weiterlesen