fix(cli): escape role and tool-call names in HTML session export - #61348
fix(cli): escape role and tool-call names in HTML session export#61348briandevans wants to merge 2 commits into
Conversation
The standalone HTML session export escapes most interpolated fields via _escape_html (args, content, reasoning, title, model, system_prompt), but a few sibling sites were missed: the message role (used both in the class attribute and the role badge) and the tool-call function name were written raw. A session whose tool call is named '<script>...</script>' (tool/MCP names are externally influenced) or whose role contains markup injects live HTML/JS into the exported artifact, which executes when the user opens the export in a browser. Route both fields through the file's own _escape_html for parity with the already-escaped siblings.
There was a problem hiding this comment.
Pull request overview
Hardens the standalone HTML session export against HTML/JS injection by ensuring role and tool-call names are escaped when rendered into exported markup, and adds regression coverage to prevent future escaping regressions.
Changes:
- Escape message
rolewhen rendered into the message header and message wrapper class. - Escape tool-call function
namewhen rendered into the tool-call header. - Add regression tests covering tool-call-name and role injection attempts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
hermes_cli/session_export_html.py |
Escapes role and tool-call fn_name in _generate_messages_html to prevent injection in exported HTML. |
tests/hermes_cli/test_session_export_html_escape.py |
Adds regression tests asserting tool-call names and roles are emitted escaped in HTML exports. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Build message HTML. Escape the role once: it feeds both an attribute | ||
| # (class) and text, and for tool/MCP messages it is externally influenced. | ||
| safe_role = _escape_html(role) | ||
| msg_class = f"message message-{safe_role} active" |
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
|
@copilot Addressed in commit 3a7b6e8: the message role now feeds two sinks on their own terms — the display badge keeps the HTML-escaped role, while the |
Related to #61343 (the stored-XSS report) and the earlier competing fix PR #61345. #61345 escapes the tool-call name and adds a restrictive CSP; this PR escapes the tool-call name and the message |
|
looks mergeable I reviewed the standalone HTML session export escaping fix against current GitHub Security evidence:
Because I validated the meaningful patch replayed onto current GitHub Signed: GPT-5.5-xhigh in Codex |
Addresses Copilot review on #61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
|
Merged via PR #61769 — your commit was cherry-picked onto current main with your authorship preserved in git log (rebase merge, commit c49d51b). Thanks @briandevans — and for the record: you were the EARLIEST submitter of the tool-call-name escape (this PR predates the merged #61345 by ~10 hours). The fn_name portion had already landed via #61345, so the salvage carried your surviving remainder: the role-badge escaping and the CSS-class token sanitization (a sink the other PRs missed entirely, since tool/MCP roles can be externally influenced), plus your test file. Good, thorough work. |
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
Addresses Copilot review on NousResearch#61348: the HTML-escaped role, while safe from injection (quotes are escaped), still contains whitespace when a crafted role is supplied, which splits the class attribute into several unintended CSS classes. Keep the escaped role for the display badge, and reduce the raw role to a single safe CSS token (alnum/-/_) for the class name. Real roles (user/assistant/system/tool) are unchanged, so the existing .message-<role> rules still match.
What does this PR do?
Closes an HTML/JS-injection gap in the standalone HTML session export (
hermes_cli/session_export_html.py). The exporter already routes most interpolated fields through the module's own_escape_html—args,content,reasoning,title,model,system_prompt— but three sibling sites on the same builder were written raw:roleinterpolated into theclass="message message-{role} active"attribute, androlein the role badge, andname({fn_name}) in the tool-call header.fn_namecomes fromtc["function"]["name"], i.e. a tool-call name — for MCP/custom tools this is externally influenced. A session containing a tool call named<script>alert(document.domain)</script>(or a craftedrolewith an attribute breakout like"><script>…) injects live HTML/JS into the exported standalone.html, which executes when the user opens the export in a browser. This is exactly the escaping-consistency defect the rest of the file already guards against; only these sites were missed.The fix reuses the file's own
_escape_htmlfor parity —roleis escaped once intosafe_role(it feeds both an attribute and text) andfn_nameis escaped inline.Related Issue
Fixes #
Type of Change
Changes Made
hermes_cli/session_export_html.py: escaperole(class attribute + role badge, via a singlesafe_role = _escape_html(role)) and the tool-callfn_namein_generate_messages_html.tests/hermes_cli/test_session_export_html_escape.py: new regression test asserting a<script>tool-call name and a markuproleare emitted escaped, never raw.How to Test
uv run --with pytest --with pytest-asyncio python3 -m pytest tests/hermes_cli/test_session_export_html_escape.py -v<script>/<img …>leaks into the output); with the fix they pass (only the<script>/<img …>escaped form appears).pytest tests/hermes_cli/test_session_export.py tests/hermes_cli/test_session_export_md.py tests/hermes_cli/test_sessions_export_md_cli.py tests/hermes_cli/test_session_export_html_escape.py -q→ 35 passed.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A