Carga segura de imágenes en ASP.NET Core: las comprobaciones que importan

Diagrama: una carga va del navegador, a través de Kestrel y el enlace de IFormFile, a RteFiles.Save, que comprueba origen, carpeta, tamaño, tipo y bytes antes del disco

Una ruta de carga acepta archivos de cualquiera que pueda llegar a ella, así que cada cosa que puede salir mal necesita su propia comprobación. Esta página repasa las comprobaciones del ejemplo de ASP.NET Core en el orden en que las encuentra una solicitud: de dónde viene la solicitud, la carpeta, el tamaño, el tipo, los bytes, el nombre guardado y cómo se sirve después el archivo. Cada una incluye la respuesta que obtuvimos cuando la forzamos a propósito.

Las comprobaciones, en orden

  1. Origen. Un POST que el navegador marca como procedente de otro sitio recibe un 403.
  2. Carpeta. La carpeta virtual debe resolverse dentro de uploads/ y existir (400 o 404). Carpetas de la galería en ASP.NET Core explica esta comprobación.
  3. Tamaño. Más de 4,000,000 bytes recibe un 413. Las solicitudes mucho más grandes se detienen antes, con los límites de Límites de tamaño de carga en ASP.NET Core.
  4. Extensión. Solo .jpg, .jpeg, .png, .gif y .webp. SVG se deja fuera porque un SVG servido desde tu propio sitio puede ejecutar scripts.
  5. Bytes. Los primeros bytes deben coincidir con la extensión.
  6. Nombre. El nombre guardado se genera, así que el nombre del cliente nunca llega al disco.

No confíes en ContentType ni en FileName

IFormFile.ContentType es el Content-Type de la parte multipart, que escribe el cliente. Un navegador lo deduce del nombre del archivo, y curl envía lo que se le diga. El ejemplo lo ignora y lee en su lugar los doce primeros bytes:

C# · RteFiles.cs
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
    ".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
    ".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
    ".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
    ".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
    _ => false,
};

Esto detiene un script renombrado a holiday.jpg. No demuestra que el archivo sea una imagen válida: un archivo puede empezar como un PNG y contener otra cosa más adelante. Lo que lo vuelve inofensivo aquí es cómo se sirve el archivo (ver más abajo). Si necesitas más, decodifica y vuelve a codificar cada carga con una biblioteca de imágenes. El ejemplo no lo hace, para no tener dependencias.

C# · RteFiles.cs
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
    var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
    if (name.Length > 40) name = name[..40].TrimEnd('-');
    return name.Length == 0 ? "image" : name;
}

El nombre base se pasa a minúsculas y se reduce a a-z, 0-9 y -, se le añaden ocho caracteres hexadecimales aleatorios de RandomNumberGenerator, y el archivo se abre con FileMode.CreateNew, que falla en lugar de sobrescribir.

Servir los archivos de forma segura

C# · RteEndpoints.cs
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(uploads),
    RequestPath = "/uploads",
    ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
        [".gif"] = "image/gif", [".webp"] = "image/webp",
    }),
    OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});

El FileExtensionContentTypeProvider predeterminado de ASP.NET Core conoce .html, .svg y .js, así que un UseStaticFiles predeterminado sobre la carpeta de cargas los serviría como páginas y scripts. El proveedor del ejemplo solo conoce los cinco tipos de imagen. Cualquier otra cosa de la carpeta recibe un 404, porque ServeUnknownFileTypes sigue desactivado. X-Content-Type-Options: nosniff indica al navegador que use el tipo declarado y no lo adivine.

Solicitudes entre sitios sin token

La respuesta habitual de ASP.NET Core a la falsificación de solicitudes entre sitios es un token antiforgery. Las solicitudes de carga y de New folder del editor son XHR que construyen rte-files.js y el plugin de la galería, y la versión actual no permite añadirles un encabezado ni un campo. Por eso, las rutas se excluyen explícitamente de la validación del token y comprueban en su lugar de dónde viene la solicitud:

C# · RteFiles.cs
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
    var site = request.Headers["Sec-Fetch-Site"].ToString();
    var origin = request.Headers.Origin.ToString();
    var foreign = site is "cross-site" or "same-site"
        || (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
            && string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
    if (foreign) throw new RteError(403, "Cross-site request refused.");
}
C# · Controllers/RteController.cs
[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]

Los navegadores envían Sec-Fetch-Site y Origin en estas solicitudes, y una página de otro sitio no puede cambiarlos. curl y el código de servidor no envían ninguno de los dos, y se les deja pasar: no tienen las cookies del usuario. Detrás de un proxy inverso que cambia el encabezado Host, añade UseForwardedHeaders para que Request.Host sea el host público. En la versión de Minimal API, la misma decisión es .DisableAntiforgery(). En .NET 8 es obligatorio, como explica Subir imágenes desde un editor de texto enriquecido en ASP.NET Core.

Nada de esto decide quién puede subir archivos. Añade [Authorize] o .RequireAuthorization() cuando tu aplicación tenga inicio de sesión.

Pruébalo

Ejecuta el proyecto de ejemplo de esta guía con dotnet run y envíale un script con nombre de foto, un SVG y una solicitud que dice venir de otro sitio:

Shell
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}

curl -F "file=@logo.svg" http://localhost:5302/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}

curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniff

Las dos primeras son 400; la tercera, 403. En la galería, el mismo holiday.jpg falso también se rechaza, pero el cuadro de diálogo muestra su propio mensaje genérico, no el del servidor:

El cuadro de diálogo Insert Gallery en la carpeta blog del ejemplo de ASP.NET Core con el mensaje Upload failed for holiday.jpg bajo cuatro fotos
La galería después de que el servidor rechazara un script llamado holiday.jpg.

La misma guía para otras plataformas

JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms

Para seguir leyendo