DEV Community

Cover image for An invisible character broke a security patch. Then it broke my review. Then it broke my review of the fix.
אחיה כהן
אחיה כהן

Posted on

An invisible character broke a security patch. Then it broke my review. Then it broke my review of the fix.

My open-source project, safari-mcp, got a security-hardening pull request from an automated scanner. The idea behind the patch was right. The patch itself un-parsed the codebase. Then the same defect infected my review of the patch, my review of the fix, and eventually the private note I wrote to remind myself to stop letting this happen.

This is a story about one character, told in three acts, and it ends with a rule I now apply to everything I publish.

The character

U+2028 LINE SEPARATOR (and its sibling U+2029 PARAGRAPH SEPARATOR) is a legal Unicode character that most terminals and editors render as a space, or as nothing at all.

In JavaScript it has two sharp edges:

  • In strings, it counts as a line terminator. If you build a single-quoted string by escaping only quotes and backslashes, a raw U+2028 in the payload can terminate the literal early. That is a classic injection vector, and it is what the pull request set out to fix. Legitimate concern, real hardening value.
  • In regex literals, the grammar forbids line terminators outright. A raw U+2028 between the slashes of /.../ is not a weird regex. It is a SyntaxError at parse time.

Keep both edges in mind. The first one motivated the patch. The second one is what the patch shipped.

Act 1: the patch that un-parsed the codebase

The PR extended my escaping helper to also escape \r, \n, U+2028 and U+2029. Seven lines added, one changed. The problem: the new .replace() calls embedded the raw characters inside regex literals, instead of the escape sequences \u2028 and \u2029.

My terminal renders U+2028 as a space. So the diff looked like it said .replace(/ /g, ...) — replace every space. That would be a different catastrophic bug. The truth was worse in a more interesting way: the file no longer parsed at all. node --check failed with a SyntaxError. The main server file imports that module, so the entire MCP server failed to start.

A security patch that prevents the server from running is, to be fair, extremely secure.

What caught it was not reading the code. It was refusing to trust rendering:

  • Python repr() on the raw diff showed the literal characters.
  • A fun tell: Python's splitlines() splits on U+2028, so a "one-line" regex was quietly two lines to Python.
  • node --check turned suspicion into proof.

I wrote all of that up in a REQUEST_CHANGES review, with a corrected snippet showing the right approach: put the ASCII escape sequence \u2028 in the source, never the raw character.

Act 2: my review had the same bug

I composed that review body in a shell heredoc. Heredocs pass Unicode through byte-for-byte. The corrected snippet I posted — the one teaching the contributor to use escape sequences — was posted with the raw characters in it, exactly where the ASCII text \u2028 was supposed to appear.

The review explaining the invisible-character bug contained the invisible-character bug.

I only caught it because, after posting, I fetched my own review body back through the API and ran repr() on it. Then I rebuilt the body in Python, constructing the escape-sequence text from character codes instead of typing it, and patched the review via the API.

Act 3: the fix was perfect. My approval was not.

The contributor fixed everything properly: escape sequences in the regex literals, a regression test that locks the full escaping recipe (backslash, quote, \r, \n, U+2028, U+2029 — in that order, so order drift fails the suite), and a documented behavior change. I verified it in a fresh clone at byte level. Full test suite green. I approved.

My approval review quoted those escape sequences. It posted with raw U+2028 in it. Again. Same failure, same detection (re-fetch, repr()), same API patch.

Epilogue: it kept going

The status row I wrote about the incident, in my own tracking file? Raw U+2028 in it.

The personal rule file I then wrote — literally titled "byte-verify anything that discusses this character" — was written to disk with two raw U+2028 bytes in it. The note about the disease was a carrier.

Four artifacts in two days. Every one of them was about the defect. Every one of them contained it.

Why this keeps happening

Text that discusses a character tends to contain that character. When I write about U+2028, some layer in the pipeline — text generation, a heredoc, an editor buffer, a clipboard — will happily emit the real code point where I intended the six ASCII characters that name it. Every layer treats it as ordinary printable Unicode and passes it through. Every renderer displays it as nothing.

So there is no natural checkpoint where the defect becomes visible. The character is only "visible" in a byte-level representation: repr(), a hex dump, an assertion. If your verification is "I looked at it and it looked fine," you have verified nothing — for this class of bug, looking is the one sense that cannot detect it.

It has a quine-like quality that I find genuinely funny now, several patches later: the document about the virus carries the virus.

The rules that survived

  1. Never trust rendering for invisible characters. Verify bytes: repr() in Python, xxd, grep -P with the code point. Eyes are the wrong instrument.
  2. Compose text about escape characters programmatically. I now build the string "\u2028" as chr(92) + "u2028" in a script. I do not type it into anything that will be published.
  3. Assert before posting. assert chr(0x2028) not in body costs one line.
  4. Re-fetch after posting and verify the published artifact. The pipeline between you and the platform can preserve what you thought you had stripped. Verify what readers actually see, not what you sent.
  5. Your own artifacts are in scope. Byte-checking a stranger's diff is the obvious part. The review of the diff, the changelog entry, the tracking note, the article — those are artifacts too, and they are more likely to carry the character, because they talk about it.

The meta part

This article discusses U+2028 roughly thirty times. It was generated by a script that constructs every single mention from character codes and asserts that the raw bytes are absent before the file is written. After publishing, I fetched the live body back and byte-verified it.

Based on my track record of the last two days: if I had skipped any of that, you would right now be reading an article about an invisible character, carrying the invisible character.

What is the nastiest invisible-character bug you have hit — and did your tooling show it to you, or did you find it by accident? Zero-width joiners in usernames, BOMs at the top of config files, non-breaking spaces in YAML — I want to hear the war stories.

Top comments (0)