Saltar al contenido
Ende Sun
Volver al blog

DevOps y seguridad

Subidas de imágenes seguras en un panel de administración con Laravel y Filament

5 min de lectura

Subidas de imágenes seguras en un panel de administración con Laravel y Filament

Subir una imagen desde un panel de administración parece la funcionalidad más inofensiva que existe. También es una de las formas más fáciles de dejar entrar en el servidor algo que nunca debería estar ahí. Cuando añadí imágenes al blog de esta web (una portada por artículo e imágenes dentro del cuerpo en Markdown), lo traté primero como una funcionalidad de seguridad y después como una de contenido. Estas son las capas que uso: todas pequeñas y ninguna suficiente por sí sola.

1. Aceptar solo los formatos que de verdad necesitas

La primera decisión es qué cuenta como imagen. FileUpload::image() de Filament acepta cualquier cosa con un tipo MIME image/*, y eso incluye SVG. Para un blog necesito fotografías y capturas de pantalla, así que la lista blanca es explícita:

public const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

FileUpload::make("{$column}_upload")
    ->image()
    ->acceptedFileTypes(self::ACCEPTED_TYPES)
    ->disk('public')
    ->directory($directory)
    ->visibility('public')
    ->maxSize($maxSizeKb);

¿Por qué no SVG? Un SVG no es un mapa de bits: es un documento XML, y puede contener elementos <script> y manejadores de eventos. Si alguien abre directamente desde tu dominio un SVG subido, ese script se ejecuta en tu origen, con acceso a todo lo que la página pueda alcanzar. Sanear SVG es posible, pero para el blog de un portfolio la respuesta segura más sencilla es no aceptarlos. Las ilustraciones que dibujo yo van con el código, no por el formulario de subida.

La misma regla vale para las imágenes que se suben dentro del editor Markdown. Filament permite configurar sus adjuntos por separado, así que necesitan la misma lista blanca y el mismo límite de tamaño; si no, el editor se convierte en una puerta trasera:

MarkdownEditor::make($name)
    ->fileAttachmentsDisk('public')
    ->fileAttachmentsDirectory('images/blog/content')
    ->fileAttachmentsAcceptedFileTypes(ImageUploadFields::ACCEPTED_TYPES)
    ->fileAttachmentsMaxSize(4096);

Llevé la lista blanca al helper compartido que usan todos los campos de subida del panel (proyectos, skills, enlaces de contacto y blog), de modo que endurecerla una vez la endurece en todas partes.

2. Limitar el tamaño

Un límite de tamaño protege tanto de accidentes como de abusos: una foto sin comprimir de 40 MB ralentiza todas las páginas que la muestran, y las subidas sin límite llenan el disco. Las portadas y las imágenes del cuerpo admiten hasta 4 MB; recursos más pequeños, como los iconos de skills, mantienen un límite menor. La validación se hace en el servidor, no solo en el componente del navegador.

3. No fiarse nunca del nombre original

Filament guarda las subidas con hashName() de Laravel, que genera un nombre aleatorio y deduce la extensión del tipo de contenido detectado, no de lo que haya escrito el usuario. Eso elimina dos tipos de problemas a la vez: trucos de rutas escondidos en un nombre de fichero y ficheros llamados como foto.png.php que esperan que un servidor mal configurado los ejecute. Además, la URL de un fichero subido no revela nada del original.

Después, las páginas guardan en el modelo la URL pública completa, así que las vistas solo imprimen una URL y nunca construyen rutas a partir de datos del usuario:

protected function storeUploadedImageUrl(array $data, string $column): array
{
    $upload = Arr::pull($data, "{$column}_upload");

    if (filled($upload)) {
        $data[$column] = asset('storage/'.$upload);
    }

    return $data;
}

4. Que la carpeta de subidas no pueda ejecutar código

Incluso con todas las comprobaciones anteriores, asumo que algún día llegará a la carpeta de subidas algo inesperado. Así que la propia carpeta debe negarse a ejecutar nada. Con Apache, basta un .htaccess dentro del directorio público de almacenamiento:

<FilesMatch "\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)$">
    Require all denied
</FilesMatch>

<IfModule mod_mime.c>
    RemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
    RemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar
</IfModule>

Es defensa en profundidad: la validación decide qué se puede guardar, y el servidor web decide que, se guarde lo que se guarde, solo se sirve como fichero estático. Si tu servidor es Nginx, el equivalente es un bloque location para la ruta de almacenamiento que nunca pase las peticiones a PHP.

5. Renderizar el contenido del usuario sin dejar pasar HTML

Las imágenes dentro de los artículos llegan como Markdown, por ejemplo ![Arquitectura](/storage/images/blog/content/x.png). Al renderizar Markdown se esconde un segundo tipo de problemas: Markdown admite HTML en bruto por defecto, y los enlaces pueden llevar URLs javascript:. El renderizador está configurado para que no sobreviva ninguna de las dos cosas:

$html = Str::markdown($markdown, [
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);

Con html_input en strip, un <img src=x onerror=...> escrito en el editor desaparece en lugar de imprimirse. Con allow_unsafe_links desactivado, una imagen o un enlace que apunte a javascript: queda neutralizado. La sintaxis de imagen de Markdown sigue generando un elemento <img> normal, así que quien escribe no pierde nada que de verdad necesite.

6. Demostrarlo con tests

Ninguna de estas capas vale mucho si una refactorización futura puede quitarla sin que nadie se entere. Los tests del panel suben ficheros reales a través del formulario de Filament y comprueban tanto el caso correcto como los rechazos:

public static function forbiddenCoverFiles(): array
{
    return [
        'svg con script' => ['evil.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
        'php' => ['1.php', '<?php system($_GET["c"]);'],
    ];
}

#[DataProvider('forbiddenCoverFiles')]
public function test_blog_post_cover_rejects_svg_and_scripts(string $name, string $content): void
{
    Livewire::test(CreateBlogPost::class)
        ->fillForm([/* ... */ 'cover_image_upload' => UploadedFile::fake()->createWithContent($name, $content)])
        ->call('create')
        ->assertHasFormErrors(['cover_image_upload']);

    $this->assertEmpty(Storage::disk('public')->allFiles('images/blog/covers'));
}

El caso válido comprueba que un PNG se guarda con un nombre aleatorio (el nombre original no debe aparecer en la URL guardada) y que el fichero existe en el disco público. Otro test renderiza un artículo con una imagen en Markdown, un <img onerror> en bruto y una imagen javascript:, y comprueba que solo la primera llega al HTML.

La lista de comprobación

  • Lista blanca explícita de tipos MIME; nada de SVG salvo que lo sanees.
  • Las mismas reglas para todas las vías de subida, incluidos los adjuntos del editor.
  • Límites de tamaño en el servidor.
  • Nombres de fichero aleatorios, con la extensión deducida del contenido.
  • Un directorio de subidas que no puede ejecutar código.
  • Markdown renderizado sin HTML en bruto y sin enlaces inseguros.
  • Tests para los rechazos, no solo para el caso feliz.

Cada punto son unas pocas líneas de código. Juntos convierten "subir una imagen" de un riesgo silencioso en una funcionalidad en la que no tengo que pensar cada vez que escribo un artículo.