Skip to content

[Bug]: Stored XSS in HTML session export via unescaped tool-call name #61343

Description

@Adolanium

Bug Description

The HTML session export (hermes session export --format html) renders the tool-call name field into the page without HTML-escaping, while every other agent-controlled field in the same file goes through _escape_html. A tool-call name is attacker-influenced, so a prompt-injected model can emit a tool call whose name is an HTML/JS payload. That payload is stored in the transcript and executes when the exported file is opened in a browser. The export template also ships no Content-Security-Policy, so the injected script runs unrestricted.

Steps to Reproduce

  1. From the repo root, render a message with a crafted tool-call name through the export renderer:
PYTHONPATH=. python - <<'PY'
from hermes_cli.session_export_html import _generate_messages_html
payload = '<img src=x onerror="alert(document.domain)">'
html = _generate_messages_html([{
    "role": "assistant", "content": "",
    "tool_calls": [{"id": "call_1", "type": "function",
                    "function": {"name": payload, "arguments": "<b>x</b>"}}],
}])
print("name unescaped:", payload in html)
print("args escaped:", "&lt;b&gt;x" in html)
PY
  1. The output shows the tool-call name is emitted as live HTML while the sibling arguments field on the same call is escaped:
name unescaped: True
args escaped: True
  1. In a real session, attacker-controlled content (a fetched web page, inbound email, or tool/MCP output) drives the model to emit the tool call. Even when an unknown tool name is refused for execution, the raw name is persisted to the transcript for agent-correction, so it reaches the export. Opening the exported HTML in a browser runs the payload.

Expected Behavior

The tool-call name is HTML-escaped like every other field, so an export opened in a browser is inert.

Actual Behavior

The tool-call name is written into HTML body context verbatim. A name such as <img src=x onerror=...> executes on open and can read and exfiltrate the export contents (which may bundle multiple sessions, system prompts, and full tool input and output). There is no Content-Security-Policy to contain it.

Affected Component

CLI (session export)

Root Cause Analysis

  • hermes_cli/session_export_html.py:709 interpolates {fn_name} into the tool-call header with no escaping. fn_name is read at :703 from tc.get("function", {}).get("name").
  • Every sibling field is escaped, which shows the intended treatment: arguments at :712, message and tool content at :720,722, reasoning at :734, title at :763,823, system prompt at :815, model at :826.
  • The export <head> (:26) has no Content-Security-Policy.
  • The raw name reaches the transcript through agent/conversation_loop.py:4465 even when the tool name is invalid, then the export reads it verbatim via hermes_state.py:5069 export_session.

Proposed Fix

Escape the tool-call name at session_export_html.py:709 with _escape_html, and add a restrictive Content-Security-Policy meta tag to the export template as defense in depth. A PR is attached.

Environment

  • OS: Windows 11
  • Python: 3.11.7
  • Hermes: 0.18.2 (reproduced on main at current head)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High — major feature broken, no workaroundcomp/cliCLI entry point, hermes_cli/, setup wizardtype/securitySecurity vulnerability or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions