Carpetas de la galería en ASP.NET Core: explorar y crear de forma segura

El cuadro de diálogo Insert Gallery dentro de la carpeta blog del ejemplo de ASP.NET Core, con cuatro fotos, la ruta /blog y la primera foto seleccionada

El botón New folder de la galería y sus mosaicos de carpetas envían un nombre de carpeta desde el navegador a tu servidor. En ASP.NET Core, ese nombre tiene que convertirse en una ruta de disco sin salir nunca de uploads/, y Windows añade sus propias sorpresas sobre lo que es un nombre de carpeta. Esta página muestra la ruta de creación de carpetas, las reglas de nombres y la comprobación de rutas, con las respuestas que obtuvimos ante entradas incorrectas.

La ruta

New folder envía action=create-folder, la carpeta abierta y el nombre nuevo a la URL de la galería, como multipart/form-data:

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

El parámetro de C# se llama op y se enlaza con [FromForm(Name = "action")], lo que lo separa del valor de ruta action propio de MVC. El método responde con el listado de la carpeta superior, así que el cuadro de diálogo muestra la carpeta nueva de inmediato:

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

En Windows, Directory.Exists no distingue mayúsculas y minúsculas, así que Blog junto a una carpeta blog existente recibe el mismo 409 que un duplicado exacto.

Nombres de carpeta

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

Permitir solo letras ASCII, dígitos, espacio, -, _ y . descarta /, \, :, % y NUL de una vez. Las demás reglas vienen de cómo trata Windows los nombres:

  • Un punto o un espacio al final. Path.GetFullPath(@"C:\app\uploads\photos.") devolvió C:\app\uploads\photos. Una carpeta creada como photos. aparecería como photos, y los dos nombres apuntarían a la misma carpeta.
  • Nombres de dispositivo. con, nul, com1 y similares, con o sin extensión, se rechazan. name=con recibe un 400.
  • Un punto al principio. Esto deja fuera .. y las carpetas ocultas como .git. El listado también omite las carpetas existentes que incumplen las reglas.

De una carpeta virtual a una ruta de disco

El navegador solo envía rutas virtuales: "", blog, blog/2026. Todas las rutas las resuelven de la misma manera:

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

Cada segmento debe pasar las reglas de nombres, así que .., C:, %2e%2e y una / inicial se rechazan antes de construir ninguna ruta. La comprobación de contención después de Path.GetFullPath es una segunda línea de defensa, y sus dos detalles importan en .NET:

  • @"C:\app\uploads-old\x.png".StartsWith(@"C:\app\uploads") es true. Compara con la raíz con un separador final, como hace el código.
  • Path.Combine(@"C:\app\uploads", @"C:\Windows") devuelve C:\Windows, y Path.Combine(@"C:\app\uploads", @"\Windows") devuelve \Windows. Un argumento con raíz descarta todo lo anterior. Nunca pases a Path.Combine un valor de la solicitud sin comprobarlo.

GetFullPath resuelve .., pero no las junctions ni los enlaces simbólicos. Una junction que alguien con acceso al servidor ponga dentro de uploads/ se puede seguir explorando.

La carpeta uploads del ejemplo de ASP.NET Core en el disco: blog con cuatro fotos, las carpetas products y team vacías, y cuatro fotos en la raíz, todas con nombres generados
Lo que son en el disco las carpetas de la galería: carpetas normales dentro de uploads/.

Pruébalo

Ejecuta el proyecto de ejemplo de esta guía con dotnet run. En la galería, abre blog y haz clic en New folder. O usa curl. La primera creación responde con el listado de blog; la segunda, con un conflicto:

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."}

Los códigos de estado son 200, 409, 400, 400 y 404. Las imágenes de la primera respuesta se han omitido aquí; el cuadro de diálogo las usa para mostrar la carpeta.

La misma guía para otras plataformas

JavaScript · PHP · MVC 5 · Web Forms

Para seguir leyendo