
Security Advisory: Unauthenticated Stored Cross-Site Scripting Leading To Administrator Account Takeover (openclaw-dashboard)
Title: OpenClaw Dashboard v3.0.0 Stored XSS via Failed Login Username Field Assigned CVE ID: CVE-2026-66418
Target Repository: https://github.com/tugcantopaloglu/openclaw-dashboard
The login endpoint records the submitted username in the audit log without any
validation. The notification panel later reads those log entries and writes them into
the page with innerHTML and no escaping. An attacker who cannot log in can still send
a failed login request whose username is a script payload. The next time the logged-in
administrator opens the notification bell, that payload runs in their browser, in the
dashboard's origin, with access to their session token.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NIntroduced in v3.0.0, which added the notification center that renders audit-log
entries. Present in v3.0.0 and every later commit up to and including the current
main (d6198d0). Not fixed at the time of writing.
The attacker is a remote party who can reach the dashboard's HTTP port but has no account and no valid credentials. This matches the login screen being reachable by anyone who can open the dashboard. The only precondition is that an administrator account already exists, which is true for any deployment past first-run setup.
The payload is stored, so no timing coordination is needed. It executes when the administrator opens the notification panel, which is a normal action offered by the UI. Once it runs, it has the same abilities as the administrator's browser: it can read the session token and call any authenticated endpoint on the victim's behalf.
Step 1. The username is logged verbatim. A login with a username that does not match the registered account reaches this branch:
// 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 comes straight from the JSON request body. There is no length limit,
character allowlist, or type check. auditLog writes it to disk as a JSON line:
// 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 escapes quotes and newlines, so the payload stays on one line and
parses back cleanly. It does not escape <, >, or /, so HTML markup survives intact.
Step 2. The log is read back. The notifications endpoint returns recent log lines to the browser:
// 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 }));
Step 3. The username is written into the DOM without escaping. The frontend builds
each notification row by string concatenation and assigns it with 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 is the attacker's string. It reaches innerHTML with no encoding, so the
browser parses it as HTML.
The Content-Security-Policy set at server.js:298 includes script-src 'self' 'unsafe-inline', so inline event handlers such as onerror are allowed to run.
Make sure an administrator account exists (any normal install). The attacker does not need its credentials.
As the unauthenticated attacker, send one failed login whose username is the payload:
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
The server replies 401 Invalid username or password and stores the payload.
The administrator logs in normally and clicks the notification bell.
The payload runs in the administrator's session. In this example it reads the session
token with the page's own getStoredToken() and uses it to overwrite the agent
instruction file AGENTS.md through POST /api/key-file. Any authenticated endpoint
can be called the same way.
The payload text stays a single log line and comes back byte-for-byte through
/api/notifications, so the injection and the execution can be confirmed separately.
Code execution in the dashboard origin as the administrator. The script can read the session token and issue any authenticated request, including editing the agent's instruction and skill files and changing the OpenClaw configuration. Because the attacker needs no account, this turns an unauthenticated network request into control of the administrator's session.
innerHTML, or build nodes with
textContent instead of string concatenation. The notification username, event, and
IP fields all need this.username on the server before logging it: cap its length and restrict it to
an expected character set.'unsafe-inline' from script-src. With inline handlers blocked, this issue
drops from code execution to harmless markup.