Cargas de imágenes seguras en ASP.NET Web Forms
Una carpeta de cargas dentro de un sitio ASP.NET es una carpeta desde la que IIS ejecutará código sin problemas. Si alguna vez acaba en ella un archivo .aspx, por un error, por FTP o por un cambio posterior, IIS lo compila y lo ejecuta. Esta página muestra las comprobaciones que hace el ejemplo de Web Forms antes de escribir un solo byte, y el bloque de web.config que mantiene uploads/ como contenido estático aunque algún día se eludan esas comprobaciones. Probamos cada capa en IIS Express.
Antes de escribir nada
StorageRoot root = GetRoot(context); // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
throw new RteException(400, "No file was uploaded.");
// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");
string dir = ResolveFolder(root, folder ?? "");
byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
int n = input.Read(data, read, data.Length - read);
if (n <= 0) break;
read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");- Lista de extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp. Un
.svgse rechaza porque puede llevar scripts. Un.phpo un.aspxse rechazan sean cuales sean sus bytes. - Tamaño: 4,000,000 bytes, con una respuesta 413 en JSON.
- Bytes mágicos, no
ContentType.HttpPostedFile.ContentTypees lo que haya enviado el navegador. Enviamos bytes PNG etiquetados comotext/htmly eso es lo que decía. El ejemplo lo ignora y compara los primeros bytes con la firma de la extensión:
static bool SignatureMatches(string ext, byte[] d)
{
switch (ext)
{
case ".jpg":
case ".jpeg":
return d.Length >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF;
case ".png":
return d.Length >= 8 && d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47
&& d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A;
case ".gif":
return d.Length >= 6 && d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38;
case ".webp":
return d.Length >= 12 && Encoding.ASCII.GetString(d, 0, 4) == "RIFF"
&& Encoding.ASCII.GetString(d, 8, 4) == "WEBP";
}
return false;
}Un archivo llamado holiday.jpg que contiene código fuente PHP no pasa esta comprobación. Un JPEG real con algo añadido al final sí la pasa. La firma demuestra el tipo, no que el archivo sea inofensivo; por eso la propia carpeta se bloquea más abajo.
En el mismo código hay dos detalles de .NET Framework. El nombre de archivo del cliente nunca se pasa a Path.GetFileName, que lanza ArgumentException con < o | en .NET Framework 4.8. El handler de carga también lee Request.Unvalidated.Files, porque la validación de solicitudes cubre los nombres de archivo: de lo contrario, un archivo llamado <script>x.png daba un HTML 500. El nombre se reduce a a-z0-9- y recibe 8 caracteres hexadecimales aleatorios, así que nada de lo que eligió el cliente llega al disco.
Dónde se puede escribir
El campo folder pasa por el mismo ResolveFolder que la galería. Cada segmento debe ser un nombre de carpeta válido, y el resultado de Path.GetFullPath debe quedarse dentro de la raíz. Carpetas de la galería en Web Forms muestra el código. En la prueba de contrato, una carga con folder=../ recibió 400, y una carpeta que no existe, 404.
uploads/ sirve archivos y no ejecuta nada
IIS decide qué ejecutar según la asignación de handlers, no según la carpeta. Para comprobarlo, pusimos una página de una línea, <%= 6*7 %>, en la raíz del sitio como probe-root.aspx. Al solicitarla devolvió 42: se ejecutó. El web.config del ejemplo lo impide para uploads/:
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
it - it gets 404, not executed. The extension allow-list also refuses to serve
anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
<system.webServer>
<handlers>
<clear />
<add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
</handlers>
<security>
<requestFiltering>
<fileExtensions allowUnlisted="false">
<add fileExtension=".jpg" allowed="true" />
<add fileExtension=".jpeg" allowed="true" />
<add fileExtension=".png" allowed="true" />
<add fileExtension=".gif" allowed="true" />
<add fileExtension=".webp" allowed="true" />
</fileExtensions>
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location><clear />y soloStaticFile. Antes de añadir la lista de extensiones, el mismo.aspxy un.ashxdentro deuploads/respondieron 404.3. No había ningún handler para ejecutarlos ni ningún tipo MIME para enviarlos como texto.fileExtensions allowUnlisted="false". Con la lista de permitidos,uploads/probe.aspxy una carga HTML guardada comouploads/probe.txtrespondieron ambos 404.7, mientras que.pngy.webpse sirvieron.X-Content-Type-Options: nosniffestaba presente en las imágenes servidas, así que un navegador no deducirá un tipo distinto a partir del contenido.
La sección handlers está bloqueada de forma predeterminada en el applicationhost.config de IIS, pero su bloque <location path="" overrideMode="Allow"> la desbloquea para los sitios, y por eso un web.config puede cambiarla. Lo comprobamos en la plantilla de IIS Express y no lo probamos en un servidor IIS completo. En un host que la bloquee, el sitio falla con un error de configuración 500.19 en lugar de ejecutar scripts sin avisar.
Pruébalo
Con el proyecto de ejemplo de esta guía en ejecución en el puerto 5401:
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5401/rte/upload
{"error":"The file is not a valid PNG image."}
curl -F "file=@fake.png;filename=x.svg" http://localhost:5401/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}Ambas respuestas son HTTP 400. Para ver el bloqueo de la carpeta, copia cualquier .aspx en uploads\ y solicítalo. Obtendrás la página 404.7 de IIS, no la salida de la página. En el cuadro de diálogo de la galería, un archivo rechazado muestra "Upload failed for <name>.". El cuadro de diálogo usa su propio mensaje en lugar del texto del servidor.
La misma guía para otras plataformas
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5