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

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:
// 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:
// 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
// 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 comophotos.aparecería comophotos, y los dos nombres apuntarían a la misma carpeta. - Nombres de dispositivo.
con,nul,com1y similares, con o sin extensión, se rechazan.name=conrecibe 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:
// 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")estrue. Compara con la raíz con un separador final, como hace el código.Path.Combine(@"C:\app\uploads", @"C:\Windows")devuelveC:\Windows, yPath.Combine(@"C:\app\uploads", @"\Windows")devuelve\Windows. Un argumento con raíz descarta todo lo anterior. Nunca pases aPath.Combineun 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.

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:
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