Catching an invisible space — my first open-source contribution (part 1)

networknt/json-schema-validator #1164PR #1267

This is a write-up of my first open-source contribution — picking an issue, tracking down the cause, fixing it, testing it, and submitting a PR — with the reasoning behind each decision along the way.

The issue

networknt/json-schema-validator is a Java library that validates JSON documents against JSON Schema. It gets real backend use — validating OpenAPI specs, for example.

The issue I picked, #1164, reported that {"format": "email"} validation accepts an email with a leading non-breaking space.

A non-breaking space looks identical to the space you type with the spacebar, but it's a different character. A regular space is U+0020 (32); a non-breaking space is U+00A0 (160). It's the character behind HTML's  , and its whole job is to be a space that refuses to line-break.

The interesting part: put a regular space (U+0020) in the same position and the validator correctly rejects it. To the eye, both strings look like " name@email.com" — but depending on the character's code point, one was rejected and one sailed through.

The issue had been open since April 2025 — more than a year. The report was short, but it came with an example input and a link to the suspect regex, which made it a good place to start, so I picked it as my first contribution.

Root cause

To find the cause I first had to reproduce the bug — and building a schema exactly as the issue described produced no errors at all. It turns out that's how JSON Schema works: format validation has always been optional in the spec, and since draft 2019-09 the default is pinned down as annotation-only — "format": "email" is informational unless you explicitly turn assertions on. Wrong format, no error, by default.

schema.validate(input, InputFormat.JSON,
    ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true)));

With that flag on, the issue reproduced. If you don't know about the flag you can't even reproduce the bug — which probably has something to do with why the issue sat untouched for so long.

Once it reproduced, I traced the cause. The library delegates email validation to an EmailValidator vendored (copied in wholesale) from Apache Commons Validator, and the regex allowing local-part characters — the part before the @ — looks like this:

// characters allowed in the email local part
"...[^\\s" + SPECIAL_CHARS + "]";
//     ^^ allow anything except whitespace and special characters

The problem is that Java's regex \s means ASCII whitespace only by default — exactly six characters: space, tab, and four control characters (LF, VT, FF, CR). U+0020 is in that set and gets filtered; U+00A0 is not. A character that doesn't count as whitespace is treated like any ordinary letter, so it passes straight through. That was the asymmetry.

While looking for a predicate to use in the fix, I found the same trap in the standard library. Character.isWhitespace(0x00A0) returns false. Java deliberately refuses to call a non-breaking space whitespace — by design, only "breakable" spaces qualify — so the no-break family, non-breaking space (U+00A0) and its relatives figure space (U+2007) and narrow no-break space (U+202F), are all excluded. Catching those takes Character.isSpaceChar, which follows Unicode's space classification instead. So the fix would need both.

The fix

Where to fix it. There were two options:

  • (A) Patch the vendored EmailValidator regex to also catch Unicode whitespace.
  • (B) Add a pre-check in the library's own EmailFormat class.

Not sure which was right, I looked at the git history first. The vendored file had only import, cleanup, and license commits — no precedent for changing its behavior. Past format bug fixes — time in #1188, uri/iri in #983 — had all landed in the library's own XxxFormat classes. The repo's convention was clearly B, so I followed it.

How much to fix. My first instinct was "reject any whitespace," until I ran into this case in the official JSON-Schema-Test-Suite:

"joe bloggs"@example.com

My first reaction was that this couldn't possibly be valid — but an ASCII space inside a quoted local part is, per spec, a perfectly legal email. "Reject any whitespace" would have broken it. Since the bug is about non-ASCII whitespace, not ASCII, I added ch > 0x7F to the condition so the ASCII range is never touched. Every ASCII email that validated before validates exactly the same after.

The final fix:

@Override
public boolean matches(ExecutionContext executionContext, String value) {
    if (containsNonAsciiWhitespace(value)) {
        return false;
    }
    return this.emailValidator.isValid(value);
}

private static boolean containsNonAsciiWhitespace(String value) {
    if (value == null) {
        return false;
    }
    return value.codePoints()
            .anyMatch(ch -> ch > 0x7F
                    && (Character.isWhitespace(ch) || Character.isSpaceChar(ch)));
}

Both predicates, for the reason above — isWhitespace alone misses U+00A0, the very character from the issue. As for iterating with codePoints(): a Java char isn't always one character; characters with high code points, like emoji, are stored as two chars. No whitespace character lives outside the single-char range, so it doesn't actually matter for this particular check — but iterating by code point is the safe habit, and I followed it.

Tests

The fix appears above, but the actual work happened tests-first: a failing test is proof you've reproduced the bug, and the same test turning green is proof you've fixed it.

One problem came up while writing them. Type an actual non-breaking space into the test source, and not even the author can tell by looking whether it's really U+00A0 or just a plain space. If it were a plain space, the test would pass while verifying the wrong thing. So the source stays pure ASCII, and the character is built from its code point at runtime:

/** U+00A0 NON-BREAKING SPACE, built from its code point so no invisible character sits in the source. */
private static final String NBSP = new String(Character.toChars(0x00A0));

Before the fix, the test failed as intended.

emailWithLeadingNbspShouldFail  ==> expected: <false> but was: <true>

After the fix I ran the full suite: all 8,471 tests passed. Since the suite includes the official JSON-Schema-Test-Suite, passing also means no spec-conformance regressions.

Submitting the PR

I wrote it up and submitted PR #1267 — 2 files, +85 lines in total.

In the description I noted that the sibling idn-email format has the same bug, but that per the scope of issue #1164 this fix was limited to email.

Not much code changed, but waiting on code review always makes me a little nervous.