diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6b3821ed03e8c..b35a70907f84c 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -682,8 +682,16 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: content_parts.append(str(part)) content = "\n".join(content_parts) - # Build message HTML - msg_class = f"message message-{role} active" + # Build message HTML. The role feeds two sinks and for tool/MCP messages + # is externally influenced, so treat each sink on its own terms: + # - display text: HTML-escape (prevents markup/JS injection). + # - class attribute: reduce to a single safe CSS token (alnum/-/_), + # so a crafted role can neither break out of the attribute nor split + # into several unintended classes. Real roles (user/assistant/system/ + # tool) are unchanged, so the `.message-` rules still match. + safe_role = _escape_html(role) + role_class = "".join(c if c.isalnum() or c in "-_" else "-" for c in str(role).lower()) + msg_class = f"message message-{role_class} active" # Delay animation for initial items delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" @@ -691,7 +699,7 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: html = f'
' html += f'
' - html += f'
{chevron_html} {role_icon} {role}
' + html += f'
{chevron_html} {role_icon} {safe_role}
' html += f'
{timestamp}
' html += '
' html += '
' @@ -706,7 +714,7 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str:
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} - {ICON_WRENCH} Tool Call: {fn_name} + {ICON_WRENCH} Tool Call: {_escape_html(fn_name)}
{_escape_html(args)}
diff --git a/tests/hermes_cli/test_session_export_html_escape.py b/tests/hermes_cli/test_session_export_html_escape.py new file mode 100644 index 0000000000000..8de3c61b23d8f --- /dev/null +++ b/tests/hermes_cli/test_session_export_html_escape.py @@ -0,0 +1,56 @@ +import re + +from hermes_cli.session_export_html import _generate_messages_html + + +def test_tool_call_name_is_escaped_in_html_export(): + messages = [ + { + "role": "assistant", + "content": "", + "timestamp": 1700000000, + "tool_calls": [ + { + "function": { + "name": "", + "arguments": "{}", + } + } + ], + } + ] + + html = _generate_messages_html(messages) + + # Raw, executable markup must never reach the standalone artifact. + assert "" not in html + # The escaped form must be present instead. + assert "<script>alert(1)</script>" in html + + +def test_role_is_escaped_in_html_export(): + messages = [ + { + "role": "", + "content": "hello", + "timestamp": 1700000000, + } + ] + + html = _generate_messages_html(messages) + + assert "" not in html + assert "<img src=x onerror=alert(document.domain)>" in html + # The class attribute must remain a single, well-formed token: a crafted + # role must not break out of it nor split into several unintended classes. + class_value = re.search(r'class="(message message-[^"]*active)"', html) + assert class_value is not None + assert " message-" in class_value.group(1) # exactly one message- class + assert class_value.group(1).count("message-") == 1 + + +def test_known_role_keeps_its_css_class(): + html = _generate_messages_html( + [{"role": "assistant", "content": "hi", "timestamp": 1700000000}] + ) + assert 'class="message message-assistant active"' in html