Su panel de admin renderiza lo que escribieron desconocidos: un XSS que publicamos, y la valla que tiene ahora
Nuestro formulario de contacto pide un sitio web. Cualquiera puede enviarlo; no hay cuenta, ni verificación, ni un límite de tasa lo bastante alto como para que el campo sea de fiar. Más tarde, alguien con sesión iniciada en el panel de admin abre la consulta y ve ese sitio web como un enlace en el que hacer clic.
Ese es todo el montaje. Texto público anónimo, renderizado en una página autenticada, en un sitio donde se espera que una persona haga clic. Es la forma clásica de un XSS almacenado y estaba en nuestra propia aplicación.
Escapar no es la protección que cree que es aquí
Filament escapa el contenido de texto, y Blade también. Si alguien envía <script>alert(1)</script> como nombre de empresa, se renderiza como caracteres visibles y no se ejecuta nada. La mayor parte del formulario queda cubierta por eso y no requiere pensar.
Y luego está esto:
TextEntry::make('website')->url(fn ($record) => $record->website)
->url() pone lo que le pasen directamente en el href. Escapar hace lo correcto con la cadena y la cadena sigue siendo javascript:alert(document.cookie), que es una URL perfectamente válida que ejecuta código al hacer clic. El escapado HTML protege contra salirse de un atributo. No tiene nada que decir sobre un valor que es peligroso quedándose dentro de uno.
Así que una URL javascript: enviada se habría ejecutado en la propia sesión del admin, en una página en la que tenía todas las razones para confiar, en el momento que el atacante eligiera escribiendo el texto y esperando.
Lo encontramos en una revisión antes de que nadie lo usara contra nosotros, y ahora está fijado por tests. Publicarlo es más útil que callarlo, porque el error no era exótico: era un método de Filament usado de la forma obvia en un campo que parecía una URL.
Dos capas, porque una es un punto único de fallo
La regla de entrada rechaza los esquemas peligrosos antes de que nada se guarde:
$scheme = parse_url(trim((string) $value), PHP_URL_SCHEME);
if (is_string($scheme) && ! in_array(strtolower($scheme), ['http', 'https'], true)) {
$fail('The website has to be a normal web address.');
}
No usamos la regla url de Laravel para esto a propósito. La gente escribe example.com, la regla url lo rechaza, y en un formulario de contacto público eso convierte un lead real en un error de validación por un https:// que falta. Esta regla solo rechaza los esquemas que son peligrosos de renderizar y deja pasar todo lo demás.
La segunda capa corre a la salida, cuando el valor está a punto de convertirse en un enlace:
public function safeWebsiteUrl(): ?string
{
$value = trim((string) $this->website);
// Whitespace and control characters are how `java\tscript:` sneaks past
// a scheme check, and no legitimate URL contains them.
if ($value === '' || preg_match('/[\x00-\x20\x7f]/', $value)) {
return null;
}
$scheme = parse_url($value, PHP_URL_SCHEME);
if ($scheme === null || $scheme === false) {
$value = 'https://'.ltrim($value, '/');
} elseif (! in_array(strtolower($scheme), ['http', 'https'], true)) {
return null;
}
return filter_var($value, FILTER_VALIDATE_URL) === false ? null : $value;
}
Dos capas en lugar de una porque la regla de entrada solo protege las filas escritas después de que existiera, y porque una regla de validación está a un refactor de que la salte un seeder, una importación o un segundo controlador que alguien añada el año que viene. El renderizador es la capa que corre cada vez.
La comprobación de caracteres de control tiene que ir primero
El orden de ese método no es estilístico. Los navegadores eliminan ciertos caracteres de una URL antes de decidir qué esquema tiene, lo que significa que java\tscript:alert(1) —con un tabulador real en medio— no es una URL javascript: para parse_url(), y sí lo es para el navegador. Lo mismo con un salto de línea. Lo mismo con un espacio inicial, que desplaza toda la cadena más allá de una comprobación ingenua de prefijo.
Compruebe primero los caracteres de control y el truco no tiene adónde ir. Ninguna URL legítima los contiene, así que no se pierde nada rechazándolos de plano.
El archivo de tests fija los que conocemos:
'javascript' => ['javascript:alert(1)'],
'tab smuggled' => ["java\tscript:alert(1)"],
'newline smuggled' => ["java\nscript:alert(1)"],
'leading space' => [' javascript:alert(1)'],
'data uri' => ['data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='],
'vbscript' => ['vbscript:msgbox(1)'],
data: merece mención aparte. Una URL data:text/html abre una página que el navegador trata como documento propio, lo que basta para montar un formulario de inicio de sesión convincente en un dominio en el que el admin confía. No es tan dramático como javascript:, y pertenece a la misma lista.
Las reglas con las que trabajamos ahora
Los dos formularios de este sitio comparten un trait para la guarda de salida y otro para la regla de entrada. Copiar cualquiera de los dos significaría dos sitios donde recordar el siguiente truco de esquema, y uno de ellos se olvidaría.
Más allá de eso, tres hábitos:
Trate cada campo enviado como hostil, incluidos los aburridos. No porque sea probable que el texto sea un ataque —casi nunca lo es—, sino porque «este campo es solo un nombre de empresa» es una afirmación sobre el comportamiento de un desconocido, y sobre eso no puede afirmar nada.
Nunca añada ->html(), ->markdown() ni {!! !!} a nada que lleve contenido enviado. Los tres existen para contenido que escribió usted. Recurrir a uno para que un mensaje enviado muestre sus saltos de línea es como esta clase de fallo se reintroduce por alguien que intenta ayudar.
Escriba el test con la carga dentro. Un test que afirma que href="javascript: nunca aparece en la página de admin renderizada es una frase que cualquiera puede leer, y falla a gritos el día que alguien vuelve a cambiar el accesor seguro por la columna sin procesar.
Dónde mirar en su propia aplicación
Busque los sitios donde un valor se convierte en un atributo en lugar de en texto. En Filament son ->url(), y cualquier uso de ->html() o ->markdown(). En Blade es {!! !!}, más cada atributo href, src, action, formaction, style y on* construido a partir de una variable. Después pregúntese, en cada uno, quién pudo escribir ese valor.
Si la respuesta es «cualquiera en internet», necesita una guarda entre la columna y la página. La nuestra tiene unas veinte líneas y lleva ahí en silencio desde entonces.
Hacemos este tipo de revisión en aplicaciones Laravel, tanto en software a medida como en aplicaciones web heredadas. Si quiere que alguien repase lo que renderiza su admin y de dónde salió, cuéntenos qué está ejecutando.
Preguntas frecuentes
¿Laravel me protege del XSS automáticamente?
Las llaves {{ }} de Blade escapan lo que imprimen, lo que cubre la mayor parte de la salida. No ayudan cuando un valor se coloca en un href, un src, un style inline o un atributo de evento: son contextos en los que una cadena correctamente escapada sigue pudiendo ser peligrosa. Y {!! !!} desactiva la protección por completo.
¿Por qué no usar la regla de validación url de Laravel en el campo?
Porque la gente escribe example.com sin esquema, y la regla url lo rechaza. En un formulario de contacto público, rechazar un lead válido por un https:// que falta tiene un coste real. En su lugar validamos el esquema —cualquier cosa que no sea http o https se rechaza— y añadimos el esquema nosotros cuando no hay ninguno.
¿Un XSS almacenado en un panel de admin es grave de verdad?
Se ejecuta con todo lo que puede hacer el admin que ha iniciado sesión, en su sesión, en una página en la que confía. En un equipo pequeño, la cuenta que lee las consultas suele ser la cuenta que puede hacerlo todo. Además, el atacante elige cuándo dispararlo, porque controla el texto y usted hace clic cuando a él le conviene.