
Aviso de seguridad: Cross-Site Scripting Almacenado No Autenticado que permite la toma de control de la cuenta de administrador (openclaw-dashboard)
Título: OpenClaw Dashboard v3.0.0 XSS almacenado a través del campo de nombre de usuario en inicio de sesión fallido ID de CVE asignado: CVE-2026-66418
Repositorio objetivo: https://github.com/tugcantopaloglu/openclaw-dashboard
El endpoint de inicio de sesión registra el nombre de usuario enviado en el registro de auditoría sin ninguna validación. El panel de notificaciones lee posteriormente esas entradas del registro y las escribe en la página con innerHTML y sin escape. Un atacante que no puede iniciar sesión puede enviar una solicitud de inicio de sesión fallida cuyo nombre de usuario sea una carga útil de script. La próxima vez que el administrador autenticado abra la campana de notificaciones, esa carga útil se ejecuta en su navegador, en el origen del dashboard, con acceso a su token de sesión.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NIntroducido en v3.0.0, que añadió el centro de notificaciones que renderiza las entradas del registro de auditoría. Presente en v3.0.0 y en todos los commits posteriores hasta el main actual inclusive (d6198d0). Sin corregir en el momento de redactar este documento.
El atacante es una entidad remota que puede alcanzar el puerto HTTP del dashboard pero no tiene cuenta ni credenciales válidas. Esto coincide con que la pantalla de inicio de sesión sea accesible para cualquiera que pueda abrir el dashboard. La única condición previa es que ya exista una cuenta de administrador, algo cierto en cualquier despliegue posterior a la configuración inicial.
La carga útil se almacena, por lo que no se necesita coordinación de tiempos. Se ejecuta cuando el administrador abre el panel de notificaciones, una acción normal que ofrece la interfaz de usuario. Una vez que se ejecuta, tiene las mismas capacidades que el navegador del administrador: puede leer el token de sesión y llamar a cualquier endpoint autenticado en nombre de la víctima.
Paso 1. El nombre de usuario se registra tal cual. Un inicio de sesión con un nombre de usuario que no coincide con la cuenta registrada llega a esta rama:
// server.js:1577-1583
if (username !== creds.username) {
recordFailedAuth(ip);
auditLog('login_failed', ip, { username });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid username or password' }));
return;
}
username proviene directamente del cuerpo de la solicitud JSON. No hay límite de longitud, lista blanca de caracteres ni comprobación de tipo. auditLog lo escribe en disco como una línea JSON:
// server.js:278-282
function auditLog(event, ip, details = {}) {
try {
const timestamp = new Date().toISOString();
const entry = JSON.stringify({ timestamp, event, ip, ...details }) + '\n';
fs.appendFileSync(auditLogPath, entry, 'utf8');
JSON.stringify escapa las comillas y los saltos de línea, por lo que la carga útil permanece en una sola línea y se vuelve a analizar limpiamente. No escapa <, > ni /, por lo que el marcado HTML sobrevive intacto.
Paso 2. El registro se lee de vuelta. El endpoint de notificaciones devuelve al navegador las líneas recientes del registro:
// server.js:2042-2051
if (req.url.startsWith('/api/notifications')) {
if (!requireAuth(req, res)) return;
const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '50');
try {
const raw = fs.readFileSync(auditLogPath, 'utf8').trim();
const lines = raw.split('\n').filter(Boolean).slice(-Math.min(limit, 200));
const events = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ events }));
Paso 3. El nombre de usuario se escribe en el DOM sin escape. El frontend construye cada fila de notificación mediante concatenación de cadenas y la asigna con innerHTML:
// index.html:5647-5653
body.innerHTML = data.events.map(e => {
const icon = notifIcons[e.event] || '📋';
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '';
const detail = e.username ? ' (' + e.username + ')' : '';
const ip = e.ip ? ' from ' + e.ip : '';
return '<div class="notif-item"><div class="notif-icon">' + icon + '</div><div class="notif-content"><div class="notif-event">' + (e.event||'').replace(/_/g, ' ') + detail + ip + '</div><div class="notif-time">' + time + '</div></div></div>';
}).join('');
e.username es la cadena del atacante. Llega a innerHTML sin codificación, por lo que el navegador la analiza como HTML.
La Content-Security-Policy definida en server.js:298 incluye script-src 'self' 'unsafe-inline', por lo que los manejadores de eventos en línea como onerror pueden ejecutarse.
Asegúrate de que existe una cuenta de administrador (cualquier instalación normal). El atacante no necesita sus credenciales.
Como atacante no autenticado, envía un inicio de sesión fallido cuyo nombre de usuario sea la carga útil:
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
El servidor responde 401 Invalid username or password y almacena la carga útil.
El administrador inicia sesión normalmente y hace clic en la campana de notificaciones.
La carga útil se ejecuta en la sesión del administrador. En este ejemplo, lee el token de sesión con la función getStoredToken() de la propia página y lo usa para sobrescribir el archivo de instrucciones del agente AGENTS.md mediante POST /api/key-file. Cualquier endpoint autenticado se puede llamar de la misma manera.
El texto de la carga útil permanece como una única línea del registro y regresa byte a byte a través de /api/notifications, por lo que la inyección y la ejecución pueden confirmarse por separado.
Ejecución de código en el origen del dashboard como administrador. El script puede leer el token de sesión y realizar cualquier solicitud autenticada, incluida la edición de los archivos de instrucciones y habilidades del agente y el cambio de la configuración de OpenClaw. Debido a que el atacante no necesita una cuenta, esto convierte una solicitud de red no autenticada en el control de la sesión del administrador.
innerHTML, o construye los nodos con textContent en lugar de concatenación de cadenas. Los campos de nombre de usuario, evento e IP de las notificaciones necesitan esto.username en el servidor antes de registrarlo: limita su longitud y restringe el conjunto de caracteres esperado.'unsafe-inline' de script-src. Con los manejadores en línea bloqueados, este problema pasa de ejecución de código a marcado inofensivo.