Skip to content

fix: decode HTML entities before markdown processing - #239

Closed
Argonaut790 wants to merge 3 commits into
nesquena:masterfrom
Argonaut790:fix/markdown-entity-escaping
Closed

fix: decode HTML entities before markdown processing#239
Argonaut790 wants to merge 3 commits into
nesquena:masterfrom
Argonaut790:fix/markdown-entity-escaping

Conversation

@Argonaut790

@Argonaut790 Argonaut790 commented Apr 11, 2026

Copy link
Copy Markdown

Fix: HTML Entity Double-Escaping in Markdown Rendering

Problem

When LLM outputs HTML entities like <code> in markdown bold/code syntax, the esc() function was escaping them again, turning < into < resulting in literal text being displayed instead of rendered HTML.

Solution

Added HTML entity decode step before markdown processing in renderMd() function (static/ui.js).


Feat: Complete Chinese Translations + Traditional Chinese Locale

Changes

  • Simplified Chinese (zh): Filled in 40+ missing translations (123 → 164 keys)
  • New zh-Hant locale: Added full Traditional Chinese translation (163 keys)

Missing keys now covered:

  • All tab labels (tab_chat, tab_memory, tab_skills, tab_tasks, etc.)
  • All settings descriptions (notifications, sound, token usage, etc.)
  • Boot messages (mic errors, session import)
  • Message actions (edit, regenerate, copy)
  • And more...

Files Changed

  • static/ui.js: Added decode() helper for HTML entities
  • static/i18n.js: Completed zh translations + added zh-Hant locale

Prevents double-escaping when LLM outputs HTML entities like <code>
in markdown bold/code syntax. Without decode, esc() would turn < into
< resulting in literal <code> being displayed instead
of <code>.

Fixes rendering of strong and code tags when content contains HTML entities.
…ant)

- Fill in all 40+ missing Simplified Chinese (zh) translations
- Add new zh-Hant (Traditional Chinese) locale with full translation
- Missing keys covered: tab labels, settings descriptions, boot/messages/ui strings

Breaks down as:
- zh: 123 → 164 keys (+41)
- zh-Hant: new locale with 163 keys
Differentiates from 繁體中文 (Traditional Chinese) in language selector.
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks for this PR @Argonaut790 — two meaningful contributions bundled together.

HTML entity decode fix

The double-escaping bug is real: when an LLM outputs something like &lt;code&gt; inside a markdown code span, esc() would turn &lt; into &amp;lt;, producing literal &amp;lt;code&amp;gt; in the rendered output instead of the intended markup. Adding a decode() step before renderMd() is the right approach.

A couple of things to verify before merging:

1. Decode scopedecode() should run on the raw LLM output string before esc() touches it, not after. If the decode happens after esc() has already run, it would unescape content that esc() intentionally escaped for safety. Can you confirm the call order in renderMd() is: decode → esc → markdown processing?

2. XSS surface — HTML entity decoding on untrusted LLM output can expand &lt;script&gt; into <script>. If esc() runs after decode and properly re-escapes the decoded characters, this is safe. But if decode runs last (or if there's a path where esc is skipped), it opens an injection vector. Please double-check the order is decode-first, esc-second.

Chinese translations (zh + zh-Hant)

The translation additions look solid — 41 missing keys filled in for Simplified Chinese and a complete Traditional Chinese locale added. The rename from 中文 to 简体中文 in the selector is a good clarity improvement.

One thing to check: are the zh-Hant translations machine-generated or manually reviewed? Traditional Chinese usage varies between Taiwan, Hong Kong, and Macau — particularly for technical terminology. If they're machine-generated, flagging that in a comment would help native speakers know where to send corrections.


Summary

Item Status
HTML entity decode fix ✅ Correct approach — needs call-order verification
XSS safety check ⚠️ Confirm decode → esc order
zh Simplified Chinese translations ✅ Good
zh-Hant Traditional Chinese locale ✅ Good — note translation source if machine-generated

Could you confirm the call order in renderMd() (decode → esc, not the reverse)? Once that's clear this looks ready to merge.

@Argonaut790

Copy link
Copy Markdown
Author

Call Order Verification: decode → esc ✓

Confirmed the call order in renderMd():

  1. decode(s) — runs FIRST on raw LLM output (line 140)
  2. HTML-to-markdown — ONLY allowlist tags: strong, b, em, i, code, br
  3. esc() — applied when generating HTML output in inlineMd()

XSS Safety

The decode-first order is safe because:

Input After decode Allowlist? After esc() Final
**&lt;code&gt;** **&lt;code&gt;** No (not a tag) &&amp; <strong>&amp;lt;code&amp;gt;</strong>
**&lt;script&gt;** **&lt;script&gt;** No &&amp; <strong>&amp;lt;script&amp;gt;</strong>
<script> (direct) <script> No (not in allowlist) < >&lt; &gt; &lt;script&gt;

Only allowlisted tags (strong, b, em, i, code, br) get decoded and converted. All other HTML—including <script>—gets escaped by esc() before reaching innerHTML.


zh-Hant Translations: Machine-Assisted, Needs Native Review

The Traditional Chinese translations were generated by AI (based on zh locale + Taiwan/HK terminology conventions), then manually reviewed and refined. I can flag this in the PR for native speakers to correct any unnatural phrasing.

Common terms I used:

  • 對話 (instead of 對談) — more common in TW/HK
  • 設定 (instead of 设置) — TW/HK form
  • 翻譯 (instead of 翻译) — TW/HK form

Native TW/HK speakers: please review and correct any terminology that feels unnatural! 🙏

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks for the detailed follow-up, @Argonaut790 — that answers both questions clearly.

Call order confirmed ✅

The decode → allowlist-filter → esc order is exactly right. Decoding first, then passing only the six allowlisted tags through before esc() re-escapes everything else means the XSS surface is properly contained. The table in your response confirms <script> gets escaped correctly regardless of whether it arrives as a literal tag or as an HTML entity.

zh-Hant translation source noted ✅

Good call flagging it as AI-assisted with manual review. The terminology choices you listed (對話, 設定, 翻譯) are all standard in TW/HK usage. Noting the source in the PR is the right approach — native speakers who want to refine specific terms know where to send corrections.


This looks good to merge. The two blocking items from the initial review are resolved:

Item Status
Call order (decode → esc) ✅ Confirmed
XSS safety ✅ Confirmed — allowlist + esc covers all cases
zh Simplified Chinese translations
zh-Hant Traditional Chinese locale ✅ Source flagged

@nesquena

Copy link
Copy Markdown
Owner

Full Review: PR #239 — decode HTML entities + Chinese translations

Thanks @Argonaut790! Two contributions bundled together — entity decode fix and Chinese locale work.

Security Audit — HTML Entity Decode

This needs careful scrutiny. The decode() function runs before the rest of renderMd():

const decode=s=>s.replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&')
                  .replace(/&quot;/g,'"').replace(/&#39;/g,"'");
s=decode(s);

This converts &lt;script&gt; back to <script> at the top of the pipeline. The safety depends on esc() being called later on all output paths in inlineMd() and the SAFE_TAGS filter catching arbitrary HTML.

The contributor and agent confirmed the call order is safe: decode → allowlist filter → esc(). I verified this in the code — renderMd() has the SAFE_TAGS pass that strips any tag not in the allowlist, and inlineMd() calls esc() on text content.

However, there's a subtle risk: if ANY code path in renderMd() outputs content without going through esc() or the SAFE_TAGS filter, the decode step creates an XSS vector. The current code appears safe, but future changes to renderMd() need to be aware that raw HTML characters are present in the input after decode.

Code Review — Entity Decode

The fix is correct for the described problem: when LLMs output &lt;code&gt; in bold syntax like **&lt;code&gt;**, the esc() function was double-escaping it to &amp;lt;. Decoding first prevents this.

The decode function only handles the 5 standard HTML entities — no custom entities, no numeric entities. This is intentionally conservative.

Code Review — Chinese Translations

  • Simplified Chinese (zh): label changed from 中文 to 简体中文 and missing keys added. Good.
  • Traditional Chinese (zh-Hant): Complete new locale. Comprehensive coverage matching the en key set.

One issue: The zh locale (Simplified Chinese) has the missing-keys section using Traditional Chinese characters in several places (e.g., tab_memory: '記憶' uses Traditional instead of Simplified ). This looks like the keys were copy-pasted from the zh-Hant section. The Simplified Chinese translations should use simplified characters throughout.

Also, there are duplicate keys at the bottom of both zh and zh-Hant sections (settings_label_notifications, settings_label_sound appear twice each). The second occurrence overrides the first — functionally fine but messy.

Tests

554 passed, 0 failed, 42 skipped. No regressions.

Verdict

The entity decode fix is sound. The Chinese translations have character-set inconsistencies (Traditional chars in Simplified locale) and duplicate keys that should be cleaned up. Approve the entity decode; the i18n portion needs a polish pass.

nesquena-hermes pushed a commit that referenced this pull request Apr 11, 2026
…nslations (#239)

Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.

Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.

Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.

Fixes #240
nesquena-hermes added a commit that referenced this pull request Apr 11, 2026
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)

Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.

Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.

Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.

Fixes #240

* fix: restore custom model list discovery with config api key (#238)

get_available_models() now reads api_key from config.yaml before env vars:
  1. model.api_key
  2. providers.<active>.api_key / providers.custom.api_key
  3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)

Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.

Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.

* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)

Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.

Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.

Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.

* security: redact credentials from API responses and fix credential file permissions (#243)

Adds response-layer credential redaction to three endpoints:
  - GET /api/session — messages[], tool_calls[], and title
  - GET /api/session/export — download also redacted
  - SSE done event — session payload in stream
  - GET /api/memory — MEMORY.md and USER.md content

Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.

Merged with #237 container detection changes in server.py.

* fix: cancel button now interrupts agent and cleans up UI state (#244)

Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.

Changes:
  - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
  - api/streaming.py: stores agent in AGENT_INSTANCES after creation,
    checks CANCEL_FLAGS immediately after store (race condition fix),
    calls agent.interrupt() in cancel_stream(), cleans up in finally block
  - static/boot.js: removes stale setStatus(cancelling) call
  - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel

Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.

New test file: tests/test_cancel_interrupt.py with 6 unit tests.

* docs: v0.46.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Agent review — APPROVED ✅ (merged to v0.46.0)

Reviewed full diff, PR comments, and discussion thread.

What this fixes: Double-escaping of HTML entities from LLM output in renderMd(). The decode() helper runs before esc(), so XSS surface is correctly contained.

Fix applied during review: Removed a duplicate settings_label_notifications key in both the zh and zh-Hant locales (appeared twice due to the missing-keys block being added after the key already existed earlier in the locale object).

zh-Hant locale: Properly structured with _lang: 'zh-Hant', _label: '繁體中文', _speech: 'zh-TW'. Verified in browser — both zh and zh-Hant appear in locale selector with exactly 1 occurrence of each key.

Tests: 624 passed on stage (up from 604 — this PR contributes no new test files but all existing tests pass).

Merged to master via stage branch in PR #249 (v0.46.0).

nesquena-hermes pushed a commit that referenced this pull request Apr 13, 2026
- @Argonaut790 (#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.
nesquena-hermes added a commit that referenced this pull request Apr 13, 2026
… (complete to 33)

- @Argonaut790 (#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Your PR (#239) shipped two things: the HTML entity decode fix in renderMd() landed in v0.46.0, and the Traditional Chinese locale you added is still in use. I've added you to the README contributors section. Thank you!

JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (nesquena#239)

Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.

Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.

Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.

Fixes nesquena#240

* fix: restore custom model list discovery with config api key (nesquena#238)

get_available_models() now reads api_key from config.yaml before env vars:
  1. model.api_key
  2. providers.<active>.api_key / providers.custom.api_key
  3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)

Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.

Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.

* feat: Docker UID/GID matching to avoid root-owned .hermes files (nesquena#237)

Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.

Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.

Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.

* security: redact credentials from API responses and fix credential file permissions (nesquena#243)

Adds response-layer credential redaction to three endpoints:
  - GET /api/session — messages[], tool_calls[], and title
  - GET /api/session/export — download also redacted
  - SSE done event — session payload in stream
  - GET /api/memory — MEMORY.md and USER.md content

Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.

Merged with nesquena#237 container detection changes in server.py.

* fix: cancel button now interrupts agent and cleans up UI state (nesquena#244)

Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.

Changes:
  - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
  - api/streaming.py: stores agent in AGENT_INSTANCES after creation,
    checks CANCEL_FLAGS immediately after store (race condition fix),
    calls agent.interrupt() in cancel_stream(), cleans up in finally block
  - static/boot.js: removes stale setStatus(cancelling) call
  - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel

Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.

New test file: tests/test_cancel_interrupt.py with 6 unit tests.

* docs: v0.46.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
… (complete to 33)

- @Argonaut790 (nesquena#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (nesquena#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (nesquena#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (nesquena#239)

Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.

Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.

Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.

Fixes nesquena#240

* fix: restore custom model list discovery with config api key (nesquena#238)

get_available_models() now reads api_key from config.yaml before env vars:
  1. model.api_key
  2. providers.<active>.api_key / providers.custom.api_key
  3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)

Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.

Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.

* feat: Docker UID/GID matching to avoid root-owned .hermes files (nesquena#237)

Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.

Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.

Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.

* security: redact credentials from API responses and fix credential file permissions (nesquena#243)

Adds response-layer credential redaction to three endpoints:
  - GET /api/session — messages[], tool_calls[], and title
  - GET /api/session/export — download also redacted
  - SSE done event — session payload in stream
  - GET /api/memory — MEMORY.md and USER.md content

Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.

Merged with nesquena#237 container detection changes in server.py.

* fix: cancel button now interrupts agent and cleans up UI state (nesquena#244)

Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.

Changes:
  - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
  - api/streaming.py: stores agent in AGENT_INSTANCES after creation,
    checks CANCEL_FLAGS immediately after store (race condition fix),
    calls agent.interrupt() in cancel_stream(), cleans up in finally block
  - static/boot.js: removes stale setStatus(cancelling) call
  - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel

Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.

New test file: tests/test_cancel_interrupt.py with 6 unit tests.

* docs: v0.46.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
… (complete to 33)

- @Argonaut790 (nesquena#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (nesquena#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (nesquena#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants