
Hace poco se publicó una vulnerabilidad en un conocido plugin de seguridad para WordPress. Sí, de seguridad. Era un XSS almacenado: un texto malicioso que queda guardado en tu sitio y se ejecuta cuando alguien abre la pantalla donde se muestra.
Lo interesante no es el plugin, sino cómo pasó, porque es un error que cualquiera que esté aprendiendo a programar plugins puede cometer. Y la lección más valiosa no fue técnica: fue sobre cómo revisamos nuestro propio código.
Qué pasó, en simple
El plugin guardaba en un registro el User-Agent de cada visita, que es el texto con el que un navegador o un robot se presenta («soy Chrome en Windows», por ejemplo). Ese texto lo escribe quien visita, así que puede poner lo que quiera.
Cuando el administrador abría el registro, una función de JavaScript preparaba el texto para mostrarlo. Convertía los símbolos < y >, pero dejaba pasar las comillas. Como el texto terminaba dentro de un atributo HTML, alguien sin cuenta en el sitio podía «cerrar» ese atributo con una comilla y agregar código que se ejecutaba en el navegador del administrador.
Lo que más enseña: esa función venía de las primeras versiones del plugin. Todas las versiones siguientes se revisaron, pero solo se miraba lo que cambiaba. El código viejo «que funciona» nunca se volvió a leer.
La regla de oro: escapar según dónde va el dato
En WordPress hay dos momentos distintos:
- Sanitizar al recibir: limpiar lo que llega (
sanitize_text_field(),absint(), etc.). - Escapar al mostrar: preparar el dato para el lugar exacto donde se va a imprimir.
El error del caso anterior fue escapar «para HTML» un dato que iba en un atributo. Cada contexto tiene su función:
- Texto dentro de HTML →
esc_html(). - Dentro de un atributo (
title,value,data-*,class) →esc_attr(). - Una URL (
href,src) →esc_url(). - HTML que debe permitir algunas etiquetas (negritas, enlaces) →
wp_kses_post(). - Datos que pasan de PHP a JavaScript →
wp_json_encode()owp_add_inline_script().
En PHP, la diferencia se ve así:
<?php
// ❌ Mal: un User-Agent con comillas rompe el atributo.
echo '<td title="' . $fila->agente . '">' . $fila->agente . '</td>';
// ✅ Bien: cada dato escapado para su contexto.
echo '<td title="' . esc_attr( $fila->agente ) . '">' . esc_html( $fila->agente ) . '</td>';
En JavaScript: deja que el navegador escape por ti
En JavaScript la tentación es armar HTML con texto y meterlo con innerHTML. Ahí es donde aparecen los escapados «caseros» a medias. La solución más segura es no escapar a mano: usar las propiedades que tratan el dato siempre como texto.
// ❌ Mal: escapado casero, se olvida de las comillas.
const limpio = dato.agente.replace( /</g, '<' ).replace( />/g, '>' );
fila.innerHTML = '<td title="' + limpio + '">' + limpio + '</td>';
// ✅ Bien: el navegador nunca interpreta el dato como HTML.
const celda = document.createElement( 'td' );
celda.textContent = dato.agente;
celda.title = dato.agente;
fila.appendChild( celda );
Con textContent y asignando el atributo como propiedad (celda.title = …), da igual qué comillas o símbolos traiga el dato: nunca se convierte en código.
Y para pasar datos desde PHP a tu script, evita armar JavaScript concatenando texto:
<?php
wp_enqueue_script( 'mi-plugin-registro', plugins_url( 'registro.js', __FILE__ ), array(), '1.0', true );
wp_add_inline_script(
'mi-plugin-registro',
'const miPluginDatos = ' . wp_json_encode( $filas ) . ';',
'before'
);
Revisa el plugin completo, no solo el último cambio
Esta es la lección grande. Una revisión que mira solo lo que cambió de una versión a otra nunca ve lo que ya estaba mal. Cada cierto tiempo, revisa el plugin entero como si fuera de otra persona.
Estos comandos te ayudan a encontrar los lugares más sospechosos en pocos segundos (cambia mi-plugin por la carpeta del tuyo):
# Datos de la petición impresos directamente
grep -rnE 'echo .*\$_(GET|POST|REQUEST|SERVER|COOKIE)' mi-plugin/ --include=*.php
# Variables impresas sin ninguna función esc_
grep -rnE 'echo \$[a-zA-Z_]+' mi-plugin/ --include=*.php | grep -v 'esc_'
# JavaScript que inserta HTML
grep -rnE 'innerHTML|outerHTML|insertAdjacentHTML|\.html\(' mi-plugin/ --include=*.js
No todo lo que aparezca es un error, pero cada línea merece que la mires con calma.
Para ir más allá, las normas de código de WordPress (WordPress Coding Standards) detectan automáticamente gran parte de las salidas sin escapar:
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs --standard=WordPress-Extra --sniffs=WordPress.Security.EscapeOutput mi-plugin/
La inteligencia artificial como segundo par de ojos
Una IA como Claude o ChatGPT puede ayudarte a revisar, siempre que le pidas algo concreto. Un ejemplo de instrucción que funciona bien:
Revisa este archivo de un plugin de WordPress buscando XSS.
Para cada dato que se imprime (en PHP o en JavaScript), dime:
1) de dónde viene y si un visitante sin cuenta puede controlarlo,
2) en qué contexto se imprime (HTML, atributo, URL, JS),
3) si la función de escape usada corresponde a ese contexto.
No propongas cambios de estilo, solo problemas de seguridad.
La IA ayuda, no firma. Puede equivocarse en ambas direcciones: inventar problemas o pasar por alto uno real. Úsala para encontrar dónde mirar y verifica tú cada hallazgo.
Si encuentras un problema
Corrígelo, publica la versión nueva y explícalo en el registro de cambios: qué versiones estaban afectadas y qué se corrigió. Contarlo con todos los datos genera más confianza que esconderlo. Y si alguien te lo reportó, agradéceselo públicamente.
¿Quieres que revisemos un fragmento de tu plugin? Pega en el foro la parte donde imprimes datos (sin claves ni datos privados) y te ayudamos a elegir la función de escape correcta para cada caso.





