Dossiers de galerie avec ASP.NET Core : parcourir et créer en toute sécurité

Le bouton New folder de la galerie et ses vignettes de dossiers envoient un nom de dossier du navigateur à votre serveur. Avec ASP.NET Core, ce nom doit devenir un chemin sur le disque sans jamais sortir de uploads/, et Windows ajoute ses propres surprises sur ce qu’est un nom de dossier. Cette page présente la route de création de dossier, les règles de nommage et la vérification du chemin, avec les réponses que nous avons obtenues pour des entrées invalides.
La route
New folder envoie action=create-folder, le dossier ouvert et le nouveau nom à l’URL de la galerie, en multipart/form-data :
// 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));Le paramètre C# s’appelle op et il est lié avec [FromForm(Name = "action")], ce qui le distingue de la valeur de route action propre à MVC. La méthode répond avec le contenu du dossier parent, si bien que la boîte de dialogue affiche immédiatement le nouveau dossier :
// 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
});Sous Windows, Directory.Exists ne tient pas compte de la casse : Blog à côté d’un dossier blog existant reçoit donc le même 409 qu’un doublon exact.
Noms de dossiers
// 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]);N’autoriser que les lettres ASCII, les chiffres, l’espace, -, _ et . exclut d’un coup /, \, :, % et NUL. Les autres règles découlent de la façon dont Windows traite les noms :
- Un point ou une espace à la fin.
Path.GetFullPath(@"C:\app\uploads\photos.")a renvoyéC:\app\uploads\photos. Un dossier créé sous le nomphotos.serait listé commephotos, et les deux noms désigneraient le même dossier. - Les noms de périphériques.
con,nul,com1et leurs semblables, avec ou sans extension, sont refusés.name=conreçoit un 400. - Un point au début. Cela exclut
..et les dossiers cachés comme.git. La liste ignore aussi les dossiers existants qui enfreignent les règles.
D’un dossier virtuel à un chemin sur le disque
Le navigateur n’envoie jamais que des chemins virtuels : "", blog, blog/2026. Toutes les routes les résolvent de la même façon :
// 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;
}Chaque segment doit respecter les règles de nommage : .., C:, %2e%2e et un / initial sont donc refusés avant même qu’un chemin soit construit. La vérification de confinement après Path.GetFullPath est une seconde ligne de défense, et ses deux détails comptent en .NET :
@"C:\app\uploads-old\x.png".StartsWith(@"C:\app\uploads")vauttrue. Comparez avec la racine suivie d’un séparateur, comme le fait le code.Path.Combine(@"C:\app\uploads", @"C:\Windows")renvoieC:\Windows, etPath.Combine(@"C:\app\uploads", @"\Windows")renvoie\Windows. Un argument enraciné écarte tout ce qui le précède. Ne passez jamais une valeur issue de la requête àPath.Combinesans l’avoir vérifiée.
GetFullPath résout .., mais pas les jonctions ni les liens symboliques. Une jonction placée dans uploads/ par une personne ayant accès au serveur reste navigable.

uploads/.Tester
Lancez le projet d’exemple de ce guide avec dotnet run. Dans la galerie, ouvrez blog et cliquez sur New folder. Ou utilisez curl. La première création répond avec le contenu de blog, la seconde avec un conflit :
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."}Les codes d’état sont 200, 409, 400, 400 et 404. Les images de la première réponse sont omises ici ; la boîte de dialogue s’en sert pour afficher le dossier.
Le même guide pour d’autres plateformes
JavaScript · PHP · MVC 5 · Web Forms