첫 오픈소스 기여 (1) — 이메일 앞의 보이지 않는 공백
첫 오픈소스 기여는 networknt/json-schema-validator의 이메일 검증 버그를 고치는 일이었습니다. JSON 문서가 JSON Schema에 맞는지 검사하는 Java 라이브러리인데요. 제가 고른 이슈 #1164에는 이메일 주소 앞에 공백이 붙어도 유효한 주소로 판단한다는 내용이 적혀 있었습니다.
그런데 공백이라고 다 통과하는 건 아니었습니다. 스페이스바로 넣은 일반 공백은 거부하고, NBSP(non-breaking space)만 통과했는데요. NBSP는 HTML의 에 해당하며 그 자리에서 줄이 바뀌지 않게 하는 문자입니다. 화면에서는 둘 다 " name@email.com"처럼 보여도 일반 공백은 U+0020, NBSP는 U+00A0으로 서로 다릅니다.
이슈는 2025년 4월에 등록돼 1년 넘게 열려 있었습니다. 설명은 짧았지만 입력값과 문제가 의심되는 정규식 링크가 있어서, 어디부터 볼지 알 수 있었습니다.
같은 입력으로 재현해 보기
일반 공백과 NBSP를 각각 이메일 앞에 붙여 비교해 보려는데, 처음에는 잘못된 이메일을 넣어도 오류가 나지 않았습니다. 이래서는 NBSP 때문인지 알 수 없으니, 이메일 형식 검증이 켜져 있는지부터 봐야 했죠.
스키마에 {"format": "email"}을 적으면 바로 검사할 것 같지만, JSON Schema에서 format은 문자열의 형식을 설명하는 정보입니다. 실제로 그 형식에 맞는지 검사할지는 별개인데요. 기본 동작이 형식 정보만 나타내도록 바뀐 내용은 draft 2019-09 변경 사항에 나와 있습니다. 이 라이브러리에서는 formatAssertionsEnabled(true)를 설정해야 이메일 형식 검증이 동작합니다.
schema.validate(input, InputFormat.JSON,
ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true)));
옵션을 켜고 나니 일반 공백은 거부하고 NBSP는 허용했습니다. 두 문자를 어디서 다르게 처리하는지 검증 코드를 따라가 봤습니다.
정규식에서 말하는 공백
검증 코드를 따라가 보니 EmailFormat은 IPv6AwareEmailValidator를 사용하고, 이 클래스는 Apache Commons Validator에서 가져온 EmailValidator를 상속하고 있었습니다. @ 앞부분에 어떤 문자를 허용할지는 상위 클래스의 정규식이 정하는 구조였죠.
private static final String SPECIAL_CHARS = "\\p{Cntrl}\\(\\)<>@,;:'\\\\\\\"\\.\\[\\]";
private static final String VALID_CHARS = "(\\\\.)|[^\\s" + SPECIAL_CHARS + "]";
여기서 [^\s...]는 공백과 특수 문자를 제외한 나머지 문자를 허용합니다. 그런데 Java 정규식의 \s는 기본적으로 ASCII 공백 여섯 문자만 포함합니다. 일반 공백과 탭, LF·VT·FF·CR입니다. NBSP는 이 범위에 들어가지 않아 정규식 검사를 통과했습니다.
정규식을 고칠지, 검사를 추가할지
NBSP를 막으려면 정규식이 인식하는 공백을 늘리거나, 정규식을 호출하기 전에 따로 검사해야 합니다. 어느 쪽이 기존 코드에 맞을지 보려고 이전 PR을 찾아봤습니다. PR #741에서는 Apache Commons Validator 코드의 자체 수정분을 제거하고 별도 패키지로 옮겼습니다.
반면 time 수정 PR #1188과 uri·iri 수정 PR #983은 각 형식을 담당하는 클래스에 검증 처리를 추가했습니다. 이 방식에 맞춰 가져온 EmailValidator는 건드리지 않고, EmailFormat에서 공백을 먼저 검사하기로 했습니다.
처음에는 공백이 있으면 전부 거부하면 되겠다고 생각했는데, 이메일에서 @ 앞부분을 따옴표로 감싼 경우는 달랐습니다. 따옴표 안에는 ASCII 공백을 넣을 수 있습니다. 공식 JSON-Schema-Test-Suite에서도 다음 주소를 유효한 이메일로 다루고 있었습니다.
"joe bloggs"@example.com
공백을 모두 막으면 따옴표 안의 ASCII 공백까지 거부하게 됩니다. 이 부분은 기존 검증기에 맡기고, 그 밖의 공백만 EmailFormat.matches()에서 먼저 걸러내기로 했습니다. 기존 검증기를 호출하기 전에 아래 검사가 들어갑니다.
@Override
public boolean matches(
ExecutionContext executionContext,
String value) {
return this.emailValidator.isValid(value);
}
@Override
public boolean matches(
ExecutionContext executionContext,
String value) {
if (containsNonAsciiWhitespace(value)) {
return false;
}
return this.emailValidator.isValid(value);
}
공백을 판별하는 메서드로는 Character.isWhitespace()가 먼저 눈에 들어왔습니다. 이름만 보면 충분할 것 같은데, 정작 처음 문제가 된 Character.isWhitespace(0x00A0)의 결과는 false였죠.
0x00A0는 NBSP의 코드 포인트U+00A0를 Java의 16진수 정수 리터럴로 적은 것입니다.
isWhitespace()만으로는 처음 문제가 됐던 NBSP를 걸러낼 수 없어 Character.isSpaceChar()도 함께 써야 했습니다. U+2007과 U+202F도 NBSP처럼 isWhitespace()에서는 제외되는 공백입니다. 두 메서드 중 하나라도 공백으로 판단하면 거부하도록 하되, ch > 0x7F 조건을 붙여 ASCII 문자는 검사에서 뺐습니다.
private static boolean containsNonAsciiWhitespace(String value) {
if (value == null) {
return false;
}
return value.codePoints()
.anyMatch(ch -> ch > 0x7F
&& (Character.isWhitespace(ch) || Character.isSpaceChar(ch)));
}
눈에 보이지 않는 문자를 테스트에 넣기
EmailFormatTest에서는 일반 이메일은 통과하고, 앞에 NBSP를 붙이면 실패하는지 확인했습니다. 여기서 NBSP를 소스에 그대로 넣으면 나중에 읽을 때 일반 공백과 구분하기 어려운데요. 누군가 실수로 일반 공백으로 바꿔도 테스트는 통과하니, 테스트 결과만으로는 알아챌 수 없습니다. 그래서 소스에도 어떤 문자인지 드러나도록 코드 포인트로 만들었습니다.
/** 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));
이 문자열을 이메일 앞에 붙이고, 검증 오류가 나오는지 확인했습니다.
@Test
void emailWithLeadingNbspShouldFail() {
List<Error> messages = validateEmail(NBSP + "name@email.com");
assertFalse(messages.isEmpty(), "email with a leading non-breaking space (U+00A0) should be invalid");
}
idn-email에도 같은 문제가 있었지만, 이번에는 이슈 #1164에서 다루는 email까지만 고치기로 했습니다. 남은 문제는 PR 설명에 적고, 공식 JSON-Schema-Test-Suite의 이메일 사례를 포함한 전체 테스트 결과와 함께 PR #1267을 올렸습니다. 첫 PR이라 리뷰를 기다리면서 조금 긴장됐습니다.
댓글 0개
첫 댓글을 남겨 주세요.