
Security Advisory: Stored Cross-Site Scripting Via Agent Messages Leading To Session Token Theft (openclaw-dashboard)
Title: OpenClaw Dashboard Stored XSS via lastMessage Session Field CVE ID: CVE-2026-66421
https://github.com/tugcantopaloglu/openclaw-dashboard
The sessions table on the dashboard's landing page shows the last message of each agent
session. That text is taken from the agent's conversation transcript and written into
the page with innerHTML and no escaping. OpenClaw is a multi-channel agent gateway, so
the message text can come from anyone who is able to talk to the agent, such as a chat
group or a webhook. A message containing an HTML payload runs as script in the
administrator's browser as soon as they open the dashboard.
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NPresent since the first public release. The vulnerable render exists in the earliest tag (v1.1.0) and in every later commit up to and including the current (d6198d0). Not fixed at the time of writing.
mainThe attacker is a party who can send a message that lands in the agent's session transcript. They do not need a dashboard account. In a typical OpenClaw deployment this includes members of connected chat channels and any source that reaches the agent through a webhook, so the attacker is often outside the operator's trust boundary.
The payload is stored in the transcript and rendered whenever the administrator views
the sessions page. That page is the default landing view and refreshes on a timer, so
the only interaction required is the administrator loading the dashboard they normally
use. AT:P reflects the one condition the attacker relies on: that the deployment
routes their messages into a session the dashboard displays.
Step 1. The message text is extracted from the transcript. The server reads the most recent message and returns its text, cut to 80 characters, with no sanitization:
// server.js:474-481
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type === 'text' && b.text) { text = b.text; break; }
}
}
if (text) return text.replace(/\n/g, ' ').substring(0, 80);
Step 2. It is exposed through the sessions API. getSessionsJson puts the text on
the lastMessage field of each session:
// server.js:538
lastMessage: getLastMessage(s.sessionId || key),
GET /api/sessions returns this array to the browser.
Step 3. It is written into the DOM without escaping. The sessions table builds each
row by string concatenation and assigns it with innerHTML:
// index.html:3758
const lastMsg = s.lastMessage ? s.lastMessage.substring(0, 60) + (s.lastMessage.length > 60 ? '…' : '') : '';
// index.html:3773
<div class="table-cell" style="..." onclick="toggleSessionExpand('${escapedKey}', event)">${lastMsg}</div>
lastMsg is placed straight into the HTML string. There is no encoding step anywhere on
this path.
The same table interpolates s.label (index.html:3770) and s.key (index.html:3767)
into the row markup the same way. Those fields have no 60-character cap, so they give an
attacker who can influence them more room for a payload.
The Content-Security-Policy at server.js:298 allows inline event handlers
(script-src 'self' 'unsafe-inline'), so an onerror handler runs.
As a party who can message the agent, send a message whose text is a short payload that fits the 60-character budget, for example:
This is 56 characters and reaches the agent's session transcript like any normal message.
Confirm the server hands the payload back unescaped:
curl -H "Authorization: Bearer ADMIN_TOKEN" http://TARGET:7000/api/sessions
The affected session's lastMessage field contains the raw `` string.
The administrator opens the dashboard. The sessions page is the default view, so no
extra navigation is needed. The row renders, the browser parses the `` tag, the
onerror handler runs, and the administrator's session token is read by the payload.
Replacing the console.log body with a fetch to a same-origin authenticated endpoint lets the payload act
with the logged in user's privileges.
Script execution in the dashboard origin as the logged in user, reachable by someone who only needs to send the agent a message. The payload can read the session token and call authenticated endpoints, including those that edit the agent's own instruction files and the OpenClaw configuration. Because it fires on the default page, the administrator does not have to take any unusual action.
lastMessage, label, and key before inserting them into the row
markup, or construct the cells with textContent rather than an HTML string.innerHTML.'unsafe-inline' from script-src so injected markup cannot execute even if an
encoding step is missed.