diff --git a/src/main/java/com/networknt/schema/format/EmailFormat.java b/src/main/java/com/networknt/schema/format/EmailFormat.java index 331fdd434..80eb31856 100644 --- a/src/main/java/com/networknt/schema/format/EmailFormat.java +++ b/src/main/java/com/networknt/schema/format/EmailFormat.java @@ -18,6 +18,7 @@ import com.networknt.org.apache.commons.validator.routines.EmailValidator; import com.networknt.schema.ExecutionContext; +import com.networknt.schema.utils.Strings; /** * Format for email. @@ -35,26 +36,11 @@ public EmailFormat(EmailValidator emailValidator) { @Override public boolean matches(ExecutionContext executionContext, String value) { - if (containsNonAsciiWhitespace(value)) { + if (Strings.containsNonAsciiWhitespace(value)) { return false; } return this.emailValidator.isValid(value); } - - /** - * Whether {@code value} contains a non-ASCII whitespace character such as - * {@code U+00A0} NON-BREAKING SPACE. The underlying validator only excludes - * ASCII whitespace, so such characters would otherwise be accepted in an - * email address. Note that {@link Character#isWhitespace(char)} deliberately - * excludes {@code U+00A0}, hence the additional - * {@link Character#isSpaceChar(char)} check. - */ - private static boolean containsNonAsciiWhitespace(String value) { - if (value == null) { - return false; - } - return value.codePoints().anyMatch(ch -> ch > 0x7F && (Character.isWhitespace(ch) || Character.isSpaceChar(ch))); - } @Override public String getName() { diff --git a/src/main/java/com/networknt/schema/format/IdnEmailFormat.java b/src/main/java/com/networknt/schema/format/IdnEmailFormat.java index 880071082..61d3fb574 100644 --- a/src/main/java/com/networknt/schema/format/IdnEmailFormat.java +++ b/src/main/java/com/networknt/schema/format/IdnEmailFormat.java @@ -1,7 +1,24 @@ +/* + * Copyright (c) 2016 Network New Technologies Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.networknt.schema.format; import com.networknt.org.apache.commons.validator.routines.EmailValidator; import com.networknt.schema.ExecutionContext; +import com.networknt.schema.utils.Strings; /** * Format for idn-email. @@ -19,6 +36,9 @@ public IdnEmailFormat(EmailValidator emailValidator) { @Override public boolean matches(ExecutionContext executionContext, String value) { + if (Strings.containsNonAsciiWhitespace(value)) { + return false; + } return this.emailValidator.isValid(value); } diff --git a/src/main/java/com/networknt/schema/utils/Strings.java b/src/main/java/com/networknt/schema/utils/Strings.java index d16f7202e..af198d08e 100644 --- a/src/main/java/com/networknt/schema/utils/Strings.java +++ b/src/main/java/com/networknt/schema/utils/Strings.java @@ -169,4 +169,20 @@ public static String[] split(String text, char delimiter) { return segments.toArray(new String[segments.size()]); } + + /** + * Whether the given string contains a non-ASCII whitespace character such as + * {@code U+00A0} NON-BREAKING SPACE. Note that {@link Character#isWhitespace(int)} + * deliberately excludes {@code U+00A0}, hence the additional + * {@link Character#isSpaceChar(int)} check. + * + * @param string the string to test, may be null + * @return true if the string contains a non-ASCII whitespace character + */ + public static boolean containsNonAsciiWhitespace(String string) { + if (string == null) { + return false; + } + return string.codePoints().anyMatch(ch -> ch > 0x7F && (Character.isWhitespace(ch) || Character.isSpaceChar(ch))); + } } diff --git a/src/test/java/com/networknt/schema/format/EmailFormatTest.java b/src/test/java/com/networknt/schema/format/EmailFormatTest.java index e8cfbf2ef..8c659e4f9 100644 --- a/src/test/java/com/networknt/schema/format/EmailFormatTest.java +++ b/src/test/java/com/networknt/schema/format/EmailFormatTest.java @@ -21,12 +21,15 @@ import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.networknt.schema.Error; import com.networknt.schema.InputFormat; import com.networknt.schema.Schema; import com.networknt.schema.SchemaRegistry; import com.networknt.schema.SpecificationVersion; +import com.networknt.schema.serialization.JsonMapperFactory; class EmailFormatTest { @@ -35,13 +38,14 @@ class EmailFormatTest { /** * Validates {@code email} against a {@code {"format": "email"}} schema with - * format assertions turned on, and returns the validation errors. + * format assertions turned on, and returns the validation errors. The email is + * serialized via the mapper so a value containing quotes is escaped correctly. */ private List validateEmail(String email) { String schemaData = "{\r\n" + " \"format\": \"email\"\r\n" + "}"; - String inputData = "\"" + email + "\""; + String inputData = JsonMapperFactory.getInstance().writeValueAsString(email); Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12).getSchema(schemaData); return schema.validate(inputData, InputFormat.JSON, executionContext -> executionContext.executionConfig(executionConfig -> executionConfig.formatAssertionsEnabled(true))); @@ -54,6 +58,16 @@ void validEmailShouldPass() { assertTrue(messages.isEmpty(), "a plainly valid email should pass"); } + /** + * A quoted local part with an ASCII space ({@code "joe bloggs"@example.com}) is a + * valid email, so the non-ASCII whitespace guard must not reject it. + */ + @Test + void quotedLocalPartWithAsciiSpaceShouldPass() { + List messages = validateEmail("\"joe bloggs\"@example.com"); + assertTrue(messages.isEmpty(), "a quoted local part with an ASCII space should stay valid"); + } + /** * Reproduces issue #1164: a leading non-breaking space (U+00A0) should make * the email invalid, just like a regular leading space does, but it is @@ -64,4 +78,16 @@ void emailWithLeadingNbspShouldFail() { List messages = validateEmail(NBSP + "name@email.com"); assertFalse(messages.isEmpty(), "email with a leading non-breaking space (U+00A0) should be invalid"); } + + /** + * The fix generalizes to other non-ASCII whitespace such as U+2003 EM SPACE + * and U+3000 IDEOGRAPHIC SPACE. + */ + @ParameterizedTest + @ValueSource(ints = { 0x2003, 0x3000 }) + void emailWithLeadingUnicodeWhitespaceShouldFail(int codePoint) { + String whitespace = new String(Character.toChars(codePoint)); + List messages = validateEmail(whitespace + "name@email.com"); + assertFalse(messages.isEmpty(), "email with a leading non-ASCII whitespace character should be invalid"); + } } diff --git a/src/test/java/com/networknt/schema/format/IdnEmailFormatTest.java b/src/test/java/com/networknt/schema/format/IdnEmailFormatTest.java new file mode 100644 index 000000000..01736cc5a --- /dev/null +++ b/src/test/java/com/networknt/schema/format/IdnEmailFormatTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.networknt.schema.format; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.networknt.schema.Error; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import com.networknt.schema.serialization.JsonMapperFactory; + +class IdnEmailFormatTest { + + /** 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)); + + /** + * Validates {@code email} against a {@code {"format": "idn-email"}} schema with + * format assertions turned on, and returns the validation errors. The email is + * serialized via the mapper so a value containing quotes is escaped correctly. + */ + private List validateIdnEmail(String email) { + String schemaData = "{\r\n" + + " \"format\": \"idn-email\"\r\n" + + "}"; + String inputData = JsonMapperFactory.getInstance().writeValueAsString(email); + Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12).getSchema(schemaData); + return schema.validate(inputData, InputFormat.JSON, + executionContext -> executionContext.executionConfig(executionConfig -> executionConfig.formatAssertionsEnabled(true))); + } + + /** Sanity check that the test harness accepts a plainly valid idn-email. */ + @Test + void validIdnEmailShouldPass() { + List messages = validateIdnEmail("name@email.com"); + assertTrue(messages.isEmpty(), "a plainly valid idn-email should pass"); + } + + /** + * idn-email allows non-ASCII letters (unlike ASCII-only email), so the narrow + * whitespace-only guard must not reject a non-ASCII letter in the local part. + */ + @Test + void idnEmailWithNonAsciiLetterShouldPass() { + // U+00FC LATIN SMALL LETTER U WITH DIAERESIS, built from its code point. + String localPart = "m" + new String(Character.toChars(0x00FC)) + "nchen"; + List messages = validateIdnEmail(localPart + "@example.com"); + assertTrue(messages.isEmpty(), "idn-email should allow a non-ASCII letter in the local part"); + } + + /** + * idn-email shares EmailFormat's delegation, so a leading non-breaking space + * (U+00A0) must be rejected here too. + */ + @Test + void idnEmailWithLeadingNbspShouldFail() { + List messages = validateIdnEmail(NBSP + "name@email.com"); + assertFalse(messages.isEmpty(), "idn-email with a leading non-breaking space (U+00A0) should be invalid"); + } + + /** + * The fix generalizes to other non-ASCII whitespace such as U+2003 EM SPACE + * and U+3000 IDEOGRAPHIC SPACE, mirroring the equivalent EmailFormatTest case. + */ + @ParameterizedTest + @ValueSource(ints = { 0x2003, 0x3000 }) + void idnEmailWithLeadingUnicodeWhitespaceShouldFail(int codePoint) { + String whitespace = new String(Character.toChars(codePoint)); + List messages = validateIdnEmail(whitespace + "name@email.com"); + assertFalse(messages.isEmpty(), "idn-email with a leading non-ASCII whitespace character should be invalid"); + } +}