Engineering Journal
Ginexys
Ginexys

Your HTML entity rendered as · because you escaped it twice

2026-08-21

TLDR: _esc(items.join(" · ")) escapes the & you just wrote. Join with the literal character instead. This is the escaping bug that survives review because both halves are individually correct.

The error

No exception. A lineage panel that read:

extract · tag
clean · verify

Where it should have read extract · tag.

The bug class

Escaping applied to a string that already contains markup you put there on purpose.

An escaper cannot distinguish an & you wrote as the start of an entity from an & that arrived in user data and must be neutralised. That is its entire job, and it does it correctly. The mistake is upstream: you mixed generated markup and untrusted content into one string, then escaped the mixture.

The concrete case

Rendering the ordered operations a tool applied to a value:

const ops = (r.ops && r.ops.length)
  ? '<span class="win-lin-ops">' + _esc(r.ops.join(" &middot; ")) + "</span>" : "";

r.ops is data from another tool, so it must be escaped. Correct.

The separator is mine, so it does not need escaping. Also correct.

But join runs first, producing extract &middot; tag, and _esc then does exactly what it is for:

.replace(/&/g, "&amp;")

giving extract &amp;middot; tag, which the browser renders as the literal text extract &middot; tag. The escaper is not broken. It was handed markup and told it was data.

Why it passes review

Look at either half and it is right. The escape call is right, because that data is untrusted. The separator is right, because it is a valid entity. The bug lives only in the order of two operations, and a diff shows them on one line.

It also fails in the least alarming way possible. No error, no exception, no broken layout. Just slightly wrong-looking text that a reader skims past, because &middot; between two words still reads as a separator.

The fix

Use the character, not its entity, so escaping is a no-op on it:

// Joined with a literal character, not an entity: the join happens BEFORE
// _esc, so "&middot;" would be escaped into visible "&middot;" text.
const ops = (r.ops && r.ops.length)
  ? '<span class="win-lin-ops">' + _esc(r.ops.join(" · ")) + "</span>" : "";

· survives escaping untouched because it contains no &, <, > or ". In a UTF-8 document the entity was never buying anything anyway.

The general form: escape each untrusted value, then join with markup. Never join into markup and escape the result.

const ops = r.ops.map(_esc).join(" &middot; ");   // also correct

That version keeps the entity and is fine, because escaping now happens per value, before any markup is added. Either fix works; what does not work is escaping after assembly.

The neighbouring version

The same ordering mistake with the opposite consequence:

el.innerHTML = "<b>" + userName + "</b>";   // no escape at all

Here markup and data are mixed and nothing is escaped, which is an injection rather than a cosmetic glitch. It is the same structural error, assembling before deciding what is trusted, and only the direction of the mistake decides whether you get a wrong character or a security bug.

Preventing the class

Escape at the leaf, not at the branch. The moment an untrusted value enters a string, escape it. Never escape something you assembled.

Prefer characters to entities. In a UTF-8 page, · are all fine literally, and a literal cannot be double-escaped. Entities are for the four characters that actually need them.

Read the rendered output, not the source. The only reason we caught this is that a screenshot of the finished panel was checked against what it was meant to say. Every unit test on that renderer passed, because they asserted the function returned a string.

Read this post in the full Engineering Journal →