An admin names a nav link Owner's "Main" Page, saves it, and it renders fine. Then they open the edit dialog to fix a typo and the textbox reads Owner's "Main" Page. The stored data is correct. The edit form is showing them entity gibberish, and if they save over it, the gibberish becomes the new label.

It looks like a display glitch. It is actually a data-integrity problem at the boundary between storage and an editable field, and there is one ordering detail that bites if you fix it carelessly.

The setup: store an escaped form, render it back to characters

The site’s nav banners let a site admin name and link the items in the top navigation. Labels are free text, so they can contain quotes, apostrophes, ampersands, the occasional <. The stored form is escaped so it survives both the serialized link blob and the rendered HTML. Quotes get round-tripped through placeholder tokens: a DisplayQuotes() helper turns the stored [quote] token into &quot; (and [squote] back into a literal apostrophe) when it builds the in-memory link object, and the entity renders correctly in the nav. That part works. Escaping untrusted text before it lands in HTML is what you are supposed to do.

The trouble starts when the same value gets loaded into an editable field.

The bug: the edit form populated the input with the escaped string

The edit dialog filled the label textbox straight from the in-memory link object:

$("#propertyLabel").val(linkObj.label)

linkObj.label already carries &quot; for every quote, because DisplayQuotes() put it there. So the input shows Owner's &quot;Main&quot; Page instead of Owner's "Main" Page. An <input> value is plain text, not HTML. It does not decode entities. It shows the literal characters, ampersand and all.

Now follow the round-trip. The admin did not type &quot;, so the field looks like garbage to them. The onkeyup handler on that input, PropertyLabelUpdate(this.value), writes whatever is in the box straight back to linkObj.label. The moment they touch the field and save, the literal text &quot; becomes the label. On the next load that string runs through the display path again, and the & in &quot; gets re-escaped to &amp;. Save through the dialog again and it compounds. Each edit through the buggy dialog can add another layer of escaping.

So an editable field that shows the escaped form and accepts it back verbatim lets entities pile up as &amp;quot;, &amp;amp;quot;, one careless save at a time. The fix has to stop it at the source, by never letting the escaped form reach the input.

The fix: decode before you put it in the input

Turn the entities back into characters before the value goes into the textbox, so the input shows what the admin actually typed. In NavBanners.js, inside PropertySectionUpdate, that meant mapping the entities a label can contain back to their characters before the .val() call:

// use a regex to replace all html entities with their actual characters
var label = linkObj.label.replace(/&quot;/g, '"');
label = label.replace(/&amp;/g, '&');
label = label.replace(/&lt;/g, '<');
label = label.replace(/&gt;/g, '>');
label = label.replace(/&quot;/g, '"');
label = label.replace(/&apos;/g, "'");
label = label.replace(/&nbsp;/g, " ");
label = label.replace(/&copy;/g, "©");
$("#propertyLabel").val(label);

Now the box shows Owner's "Main" Page, the admin edits real text, and the round-trip stops compounding. (Yes, &quot; is in there twice. That is the literal diff, not a tidied-up version.)

The detail that will burn you on arbitrary input: where &amp; goes in the chain

Any decode done by chained replace has a trap, and the fix above does not avoid it. It gets away with it. The order matters because &amp; is the entity for the ampersand, and every other entity starts with an ampersand.

Take a stored value of &amp;lt;, the escaped form of the literal text &lt; (someone typed the four characters ampersand-l-t-semicolon and wanted them to survive). Decode &amp; to & early and &amp;lt; becomes &lt;. Then the &lt; rule fires on that result and turns it into <. You just decoded a layer that was not yours to decode, and the user’s literal &lt; text became a < character. Run that through the save path and the data is wrong in the other direction.

The rule for arbitrary input: when you decode a set of entities by chained string replacement, handle &amp; last. Decode all the named entities while the ampersands are still escaped, so a &amp; that is protecting some user’s literal entity text does not get unwrapped early and let the next rule chew through it. Decode &amp; only at the end, when any remaining &amp; means “the user wanted an ampersand here.”

The same hazard exists on the encode side in reverse. When you escape, you escape & first, before <, >, and the rest, so you do not double-escape the ampersands you just introduced. Escape & first, decode & last.

Now look at the actual fix. It decodes &amp; second, right after the first &quot;, which breaks the rule I just gave you. It is fine here, and that is the interesting part. For this bounded set of labels, the entities that realistically show up are quotes, apostrophes, and the odd ampersand, not nested constructions like &amp;lt;. No other handled entity can be produced by an early &amp; unwrap in a way that matters for these inputs, so the early decode never bites. If you copy this chain into something that takes arbitrary input, move the &amp; decode to the end. The bounded version works on luck you do not have in the general case.

Every editable field is a decode/encode boundary

The edit form is not just another display surface. A render target and an <input> have opposite requirements even though they hold the same logical value.

  • The render path wants the value HTML-encoded, because it is dropping text into HTML.
  • The input path wants the value decoded, because an <input> holds plain text and will show entities literally.
  • The save path wants to encode exactly once, on the way in.

Wire those three correctly and the value survives any number of round-trips. Populate the box with the escaped string and the next save writes the escaped text back as if the user had typed it, because from the form’s point of view they did. Escaping on the way in was correct. What got missed is that the way out of storage into an editable field needs the inverse transform.

For every editable surface you need three things in agreement: decode when you load the value into the field, edit the decoded text, and re-escape once when you save. Miss the decode and you get no error. You get a slow accretion of &amp;quot;, then &amp;amp;quot;, that nobody notices until an admin’s nav link is several layers deep and the only way out is a manual cleanup.

Audit the .val() calls

Anywhere you call .val(storedValue) on a field whose source is an escaped string, this bug is waiting. It will not throw an error, and it folds your users’ data over on itself, one well-intentioned save at a time. Check that every editable field decodes on load and encodes exactly once on save, and that any chained decode handles &amp; last.