Skip to content

fix(export): escape tool-call name in HTML session export - #61345

Merged
teknium1 merged 1 commit into
NousResearch:mainfrom
Adolanium:fix/session-export-escape-tool-call-name
Jul 9, 2026
Merged

fix(export): escape tool-call name in HTML session export#61345
teknium1 merged 1 commit into
NousResearch:mainfrom
Adolanium:fix/session-export-escape-tool-call-name

Conversation

@Adolanium

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a stored-XSS-class escaping gap in the HTML session export.

_generate_messages_html interpolates the tool-call name into the tool-call header (hermes_cli/session_export_html.py:709) without escaping, while every other agent-controlled field in the same renderer goes through _escape_html (arguments :712, message and tool content :720,722, reasoning :734, title :763,823, system prompt :815, model :826). The name is the only unescaped field.

A tool-call name is attacker-influenced. A prompt-injected model can emit a tool call whose name is an HTML payload such as <img src=x onerror=...>. The name does not need to be a real tool: an unknown name is refused for execution, but the raw assistant message is still persisted for agent-correction (agent/conversation_loop.py:4465), so the payload reaches the transcript and then the export via hermes_state.py:5069 export_session. Opening the exported HTML in a browser runs the payload, and the template ships no Content-Security-Policy to contain it.

The fix escapes the tool-call name like every sibling field, and adds a restrictive Content-Security-Policy meta tag to the export template. The CSP blocks inline and external scripts and inline event handlers, so a future unescaped sink cannot execute, while still allowing the inline styles and the Google Fonts the template already loads. Scope stays on the export renderer.

Related Issue

Fixes #61343

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/session_export_html.py
    • Escape the tool-call name at :709 with _escape_html, matching every sibling field.
    • Add a Content-Security-Policy meta tag to the export template <head>: default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com. This blocks scripts and inline handlers while preserving the existing inline styles and web fonts.
  • tests/hermes_cli/test_session_export_html.py
    • A tool-call name containing an <img onerror=...> payload is escaped in the export output.
    • The sibling arguments field stays escaped.
    • The export sets the Content-Security-Policy and still permits the web fonts.

How to Test

  1. uv run --with pytest pytest tests/hermes_cli/test_session_export_html.py -q (3 new tests pass). Adjacent tests/hermes_cli/test_session_export.py and test_session_export_md.py still pass (15 tests).
  2. Proof the fix works: with session_export_html.py reverted to main, the escaping test fails because the name is emitted verbatim as <img ...>. With the fix the output contains the escaped &lt;img ... and the CSP meta tag.
  3. Manual: export a session whose transcript contains an assistant tool call named <img src=x onerror=alert(1)> and open the HTML in a browser. Before the fix the handler fires. After the fix the name renders as inert text and the CSP blocks inline handlers.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows or N/A
  • I've considered cross-platform impact (Windows, macOS) or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior or N/A

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround labels Jul 9, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

Security evidence:

  • trust boundary: a stored session transcript can contain attacker-influenced assistant tool-call metadata, and the HTML exporter renders that transcript into a browser-opened artifact.
  • source/sink/invariant: _generate_messages_html now escapes the tool-call function.name, but the new CSP also constrains the page's own scripts; that CSP must not break the existing export UI.
  • current-main reproduction: in a current-main worktree, probe_session_export_html.py imported /home/mac/hermes/hermes-agent/worktrees/hermes-review-pool/worktrees/pr-61345-main/hermes_cli/session_export_html.py and showed the raw <img src=x onerror="alert(1)"> tool-call name is emitted.
  • PR-head or patch-replay validation: in the PR-head worktree, the same probe imported /home/mac/hermes/hermes-agent/worktrees/hermes-review-pool/worktrees/pr-61345-head/hermes_cli/session_export_html.py and showed the tool-call name is escaped, but the generated multi-session export has Content-Security-Policy without script-src, includes the inline showSession script, and starts with multi_initial_active_count 0.
  • positive/negative cases: tests/hermes_cli/test_session_export_html.py passed, and the adjacent session export suites passed as 18 passed; however, those tests do not cover the multi-session initialization path where .session-view elements are hidden until inline JavaScript marks one active.
  • residual bypass search: the XSS sink in the tool-call name is covered, but the defense-in-depth CSP currently blocks the template's own inline session-switching/search/toggle script, so multi-session exports render with no visible session content unless the CSP/script integration is fixed.
  • reviewer validation: CodeRabbit completed in full-mode reviewer tooling and independently reported the CSP blocking the export page's inline script.

The fix should either keep the CSP while permitting the template's own script with a nonce/hash-based script-src, or make the multi-session export work without inline script. As written, the submitted branch turns a security fix for exported HTML into a functional regression for multi-session HTML exports.

Signed: GPT-5.5-xhigh in Codex

The HTML session export interpolated the tool-call name into the page
without escaping, while every sibling field went through _escape_html. A
tool-call name is attacker-influenced, so a prompt-injected model can emit
a name containing HTML that executes when the export is opened in a browser.

Escape the tool-call name like the other fields.
@Adolanium
Adolanium force-pushed the fix/session-export-escape-tool-call-name branch from f78f79f to 301a3b6 Compare July 9, 2026 08:18
@Adolanium

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. You are right that the CSP broke the multi-session export.

I dropped the CSP from this PR and kept the escaping fix, which is the actual vulnerability fix. Escaping the tool-call name at the sink fully neutralizes the reported XSS, so the security goal is met without the CSP.

Making the CSP work here is not a one-liner. The export ships an inline script block plus interpolated inline onclick handlers (onclick="showSession('{sid}')" at session_export_html.py:763). A hash or nonce based script-src can allow the static script block, but the per-session inline handlers cannot be covered by a fixed hash, so a correct CSP needs the template refactored to event delegation first. I would rather not bundle a template refactor into a security fix.

Changes on the updated branch:

  • session_export_html.py: escape the tool-call name only, no CSP.
  • tests: keep the name and arguments escaping tests, and add test_multi_session_export_keeps_switcher_script so the switcher path is covered.

Happy to open a follow-up that moves the inline handlers to event delegation and adds a nonce-based CSP if you want the defense in depth.

@egilewski

Copy link
Copy Markdown
Contributor

fully addressed

Security evidence:

  • trust boundary: a stored session transcript can contain attacker-influenced assistant tool-call metadata, and the HTML exporter renders that transcript into a browser-opened artifact.
  • source/sink/invariant: _generate_messages_html must HTML-escape the tool-call function.name before inserting it into the tool-call header, while preserving the existing multi-session export UI.
  • current-main reproduction: with PYTHONPATH pinned to /home/mac/hermes/hermes-agent/worktrees/hermes-review-pool/worktrees/61345-20260709T090101Z-main-refresh, probe_session_export_html.py imported current GitHub main e7648d59129ab1709ed111eca3b1d5f11408adac and showed tool_name_raw_present True, tool_name_escaped_present False, and arguments_escaped_present True.
  • PR-head or patch-replay validation: with PYTHONPATH pinned to /home/mac/hermes/hermes-agent/worktrees/hermes-review-pool/worktrees/61345-20260709T090101Z-pr, the same probe imported the PR-head module and showed tool_name_raw_present False, tool_name_escaped_present True, arguments_escaped_present True, has_csp False, has_show_session_script True, has_first_data_id True, and has_second_view True.
  • positive/negative cases: tests/hermes_cli/test_session_export_html.py passed as 3 passed, adjacent export suites passed as 18 passed, py_compile hermes_cli/session_export_html.py tests/hermes_cli/test_session_export_html.py passed, git diff --check passed, and git merge-tree --write-tree e7648d59129ab1709ed111eca3b1d5f11408adac 301a3b62e6cdfdb7ec0efc958075e9eb87719d00 succeeded.
  • residual bypass search: the earlier CSP regression is gone, the multi-session switcher script remains present, and the only nearby unescaped session IDs are Hermes-generated IDs outside the attacker-controlled tool-call-name path fixed by this PR.
  • reviewer validation: CodeRabbit completed against base 1d689e19203281228878ac6770d4a6700d4ae385 before current main advanced only in unrelated transport/provider tests; its two findings were in agent/model_metadata.py, outside this PR's changed files, and were not used as blockers.

The updated branch addresses the previous blocker by dropping the CSP change that broke multi-session exports while keeping the actual sink fix for the stored tool-call-name XSS. I did not find another high-confidence PR-scoped blocker.

Signed: GPT-5.5-xhigh in Codex

@teknium1
teknium1 merged commit 1f57ed2 into NousResearch:main Jul 9, 2026
31 checks passed
@teknium1

Copy link
Copy Markdown
Contributor

Merged — thanks @Adolanium! Clean sink fix, and good call dropping the CSP after review; the event-delegation + nonce-CSP defense-in-depth pass is a sensible follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants