D
P
0
← All articles Baca dalam Bahasa Indonesia

Caching & CDN: Deployed, but Nothing Changed

Every Exported Email Reads `[email protected]` and One Line on the Page Stops at `<`? That's Edge Email Obfuscation

· · 5 min read
Every Exported Email Reads `[email protected]` and One Line on the Page Stops at `<`? That's Edge Email Obfuscation

One column in the exported spreadsheet held the exact same value on every single row. Not similar, identical, from the first row to the last:

[email protected]
[email protected]
[email protected]
...

It was a contact list. Every row came from a different person, so there is no sane path by which all of them end up the same. I had built the export the way I always do when a WordPress admin screen offers no export button: open the list, run a snippet in the console, read textContent out of each cell, assemble CSV rows.

My first guess was that my selector had drifted onto the wrong column. My second was that some privacy plugin was masking addresses on the list screen. Both were wrong, and both only fell apart once I stopped reading my script's output and started reading the HTML that was actually sitting inside the cell.

The address really is not in the HTML

Here is what the cell contained:

<td class="column-email">
  <a class="__cf_email__" data-cfemail="a1c4ccc0c8...">[email protected]</a>
</td>

That visible text is not a partially masked address. It is a placeholder, and it is constant for everyone. The real address moved into the data-cfemail attribute as hex. So textContent did not misread anything. It read, perfectly, a string that carries no information.

The culprit is the CDN's Email Obfuscation feature. It scans HTML on the way through, finds anything shaped like an email address, and swaps in that anchor. In an ordinary browser a small script from the CDN decodes data-cfemail and puts the real text back, so visitors never notice. My console snippet ran earlier, or at least read the DOM at the wrong moment, and what it got was the raw form.

The part that matters: the rewrite happens at the edge, not at the origin. PHP on the server emits the correct address. What reaches the browser is no longer that.

Decoding it

The encoding is simple and not a secret. The first hex pair is an XOR key, and each pair after it is one byte of the address XORed with that key.

function decodeCfEmail(hex) {
  const key = parseInt(hex.substr(0, 2), 16);
  let out = '';
  for (let i = 2; i < hex.length; i += 2) {
    out += String.fromCharCode(parseInt(hex.substr(i, 2), 16) ^ key);
  }
  return out;
}

The temptation is to apply that only to the email column, since that is where the damage is visible. I did exactly that, and the result was still full of holes. Addresses show up in places you do not plan for, buried inside message bodies people typed themselves, and every one of those got eaten by the same rewrite.

So I moved the replacement down to the cell level instead of the column level. Clone the cell, swap every __cf_email__ node inside it for decoded text, then read the text:

function cellText(td) {
  const clone = td.cloneNode(true);
  clone.querySelectorAll('.__cf_email__').forEach((node) => {
    const decoded = decodeCfEmail(node.dataset.cfemail || '');
    node.replaceWith(document.createTextNode(decoded));
  });
  return clone.textContent.trim();
}

After that the email column held genuinely different addresses per row, and the ones riding along inside message bodies survived too.

Weeks later, a symptom that looked unrelated

On a different project I added a small line to a form: the requester's name followed by the address the request was tied to, so the person could see which account they were acting as. It shipped, and it stopped halfway:

REQUESTING AS Some Person <

Right at the <, then nothing. I checked the PHP output and the address was there, complete. I checked the template and nothing was wrong with it. In the spot where the address belonged, the HTML that arrived at the browser carried the same __cf_email__ markup that had wrecked my export.

The template was correct and the page was wrong. That sentence took a while to accept, because for years those two had been the same thing.

The fix is one line and no JavaScript

For addresses you actually mean to display, the CDN ships its own opt-out marker. Wrap the part it must not touch and the rewrite walks past it:

<!--email_off-->Some Person &lt;address@example.com&gt;<!--/email_off-->

No need to disable obfuscation site-wide, and no need to decode anything client-side. Only one thing changes: this fragment is declared not to be an address in need of hiding.

For the export, opt-out was not the answer. I did not want customer addresses sitting bare in the admin HTML just to make my script's life easier, so the decoder stayed.

Why it took me so long

My habit, whenever something is odd on a CDN-fronted site, is to verify at the origin. Skip the edge, hit the server directly, see what the application really emits. It is the fastest way to establish that I am chasing a code bug and not a stale cache.

That habit is right for caching problems and actively misleading for rewriting problems. The origin always answered with the correct address, because the origin never had a problem. Every time I checked there I was gathering evidence that nothing was broken, while the breakage lived in the exact layer I had deliberately removed from the test. My instrument worked perfectly and was measuring the wrong object.

Once a CDN edits the body of your HTML, the edge stops being a courier and becomes the only honest place to look. The origin only tells you what the application intended. What people read, and what scripts read, are the bytes that leave the edge.

What I took away

Those two symptoms turned out to be the same bug seen from two sides. I found the first with a script reading HTML, and the second through someone's eyes reading a page. If I had understood the cause during the first case, the second would never have shipped.

The distinction I draw now is this. When a feature's output is layout, checking the template is usually enough, because layout gets recomputed in the browser from rules I wrote. When the output is content, check the bytes that actually arrive. Content can be edited by anything standing between the application and the reader, and a CDN stands exactly there.