제어문자 섞인 전문 대응: 수신은 정규화, 송신은 정제

수신측 - JacksonUtil.escapeControlChars() 적용 범위 확대
  JsonReader 에만 걸려 있어 TemplateCodeConvertAdapterErrorMsgHandler 등
  직접 readTree() 하는 경로가 "Illegal unquoted character ((CTRL-CHAR, code 10))"
  로 실패했다. JacksonUtil.readTree() 공용 진입점과 payload 를 파싱하는
  핸들러/필터에 적용한다.

송신측 - MessageUtil.stripControlChars() 신규
  TemplateAdapterErrorMsgHandler.render() 가 Matcher.quoteReplacement() 로만
  치환하고 있었다. 이것은 정규식 치환용이라 JSON/XML 이스케이프가 아니어서,
  값에 개행이 있으면 깨진 JSON 을 만들어 응답으로 내보냈다. 실제로 위 파싱
  오류의 원문도 상대 전문이 아니라 이 렌더 결과였다.

  템플릿이 JSON/XML 어느 쪽인지 알 수 없어 포맷별 이스케이프가 불가능하므로
  제어문자를 걸러낸다. 탭/개행/CR 은 공백으로, 나머지 제어문자와 DEL 은 제거.
  render() 의 스칼라 치환과 renderRow() 의 행 필드 치환에만 적용해
  템플릿 서식(들여쓰기/개행)은 보존한다.

검증: gradlew compileJava BUILD SUCCESSFUL.
      databind 2.12.7 런타임에서 0x00~0x1F 전수 + 로그 재현 케이스 통과.
This commit is contained in:
curry772
2026-08-28 14:28:01 +09:00
parent f1b67df867
commit 6dee34e085
9 changed files with 76 additions and 10 deletions
@@ -23,6 +23,7 @@ import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.property.PropManager; import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.Logger; import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.MessageUtil; import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.message.StandardItem; import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage; import com.eactive.eai.message.StandardMessage;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
@@ -171,7 +172,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
} else { } else {
// ${path} / ${callprop.키} / ${exception.필드} 스칼라 변수 // ${path} / ${callprop.키} / ${exception.필드} 스칼라 변수
String expr = matcher.group(3).trim(); String expr = matcher.group(3).trim();
replacement = resolveScalar(expr, msg, callProp, exception); // 치환값에 제어문자가 섞이면 렌더 결과가 깨진 JSON/XML 이 된다.
// (개행이 든 값 → {"outpMsgDesc":"오류상세<개행>..."} → 수신측 파싱 실패)
// 템플릿 포맷을 알 수 없으므로 이스케이프 대신 제어문자를 걸러낸다.
replacement = MessageUtil.stripControlChars(resolveScalar(expr, msg, callProp, exception));
} }
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
} }
@@ -400,7 +404,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
String value = ""; String value = "";
StandardItem item = row.get(fieldName); StandardItem item = row.get(fieldName);
if (item != null) { if (item != null) {
value = StringUtils.defaultString(item.getValue()); // render() 의 스칼라 치환과 동일한 이유로 제어문자를 걸러낸다
value = MessageUtil.stripControlChars(StringUtils.defaultString(item.getValue()));
} }
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value)); varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
} }
@@ -60,7 +60,9 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr
return responseMsessage; return responseMsessage;
} }
JsonNode rootNode = OBJECT_MAPPER.readTree(jsonStr); // 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
// (이미 표준을 지킨 JSON 이면 escapeControlChars 는 원본을 그대로 반환한다)
JsonNode rootNode = OBJECT_MAPPER.readTree(JacksonUtil.escapeControlChars(jsonStr));
boolean modified = false; boolean modified = false;
for (String rawField : fieldsValue.split(",")) { for (String rawField : fieldsValue.split(",")) {
@@ -64,7 +64,7 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다. // 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다. // 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
ObjectMapper mapper = JacksonUtil.newNumberSafeMapper(); ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
ObjectNode jsonNode = (ObjectNode) mapper.readTree(sendData); ObjectNode jsonNode = (ObjectNode) mapper.readTree(JacksonUtil.escapeControlChars(sendData));
ObjectNode headerPart = (ObjectNode) jsonNode.get("header_part"); ObjectNode headerPart = (ObjectNode) jsonNode.get("header_part");
if( headerPart.get("mciIntfId") != null && headerPart.get("mciIntfId").asText().trim().length() > 0 ) { if( headerPart.get("mciIntfId") != null && headerPart.get("mciIntfId").asText().trim().length() > 0 ) {
@@ -6,6 +6,7 @@ import java.util.Properties;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonMappingException;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -66,6 +67,6 @@ public class JsonToSetStatusFilter implements HttpAdapterFilter {
orgMessageString = (String) message; orgMessageString = (String) message;
} }
return mapper.readTree(orgMessageString); return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
} }
} }
@@ -107,7 +107,7 @@ public class JsonToStdConverterFilter implements HttpAdapterFilter {
orgMessageString = (String) message; orgMessageString = (String) message;
} }
return mapper.readTree(orgMessageString); return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
} }
} }
@@ -33,7 +33,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
for(Iterator<String> it = rootNode.fieldNames(); it.hasNext();) { for(Iterator<String> it = rootNode.fieldNames(); it.hasNext();) {
String fieldName = it.next(); String fieldName = it.next();
String value = rootNode.get(fieldName).asText(); String value = rootNode.get(fieldName).asText();
JsonNode jsonNode = mapper.readTree(value); JsonNode jsonNode = mapper.readTree(JacksonUtil.escapeControlChars(value));
replacedJson.set(fieldName, jsonNode); replacedJson.set(fieldName, jsonNode);
} }
@@ -65,7 +65,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
orgMessageString = (String) message; orgMessageString = (String) message;
} }
return mapper.readTree(orgMessageString); return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
} }
} }
@@ -16,6 +16,7 @@ import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.adapter.AdapterManager; import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.common.property.PropManager; import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -203,7 +204,7 @@ public class KbankHmacSha256VerifyFilter implements HttpAdapterFilter {
orgMessageString = (String) message; orgMessageString = (String) message;
} }
return mapper.readTree(orgMessageString); return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
} }
@Override @Override
@@ -191,7 +191,9 @@ public final class JacksonUtil {
return null; return null;
} }
return objectMapper.readTree(jsonStr); // 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
return objectMapper.readTree(escapeControlChars(jsonStr));
} }
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException { public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
@@ -47,6 +47,61 @@ public final class MessageUtil {
} }
/**
* 문자열에서 제어문자(0x00~0x1F, 0x7F)를 걸러낸다.
*
* 값이 JSON / XML / 고정길이 전문 중 어디로 나갈지 모르는 자리 - 대표적으로 템플릿 치환 -
* 에서 쓴다. 출력 포맷마다 이스케이프 방식이 달라 포맷을 알아야 하는데, 제어문자를 아예
* 걷어내면 포맷과 무관하게 안전해진다.
*
* 각 포맷에서 제어문자가 일으키는 문제:
* - JSON : raw 제어문자는 문자열 안에 올 수 없다
* ("Illegal unquoted character ((CTRL-CHAR, code 10))")
* - XML : 0x09/0x0A/0x0D 를 제외한 제어문자는 문자 참조로도 표현할 수 없어
* 수신측 파서가 거부한다
* - 전문 : 제어문자도 1바이트를 차지해 고정길이 자리수가 어긋난다
*
* 처리 규칙
* - 탭/개행/캐리지리턴(0x09/0x0A/0x0D) : 구분 의미가 있으므로 공백 1칸으로 치환
* - 그 외 제어문자 및 DEL(0x7F) : 제거
*
* 연속 공백을 합치지는 않는다(CRLF 는 공백 2칸이 된다). 값 변형을 최소화하기 위함이다.
*
* @param s 원본 문자열. null/빈 문자열이면 그대로 반환
* @return 제어문자가 걸러진 문자열. 걸러낼 게 없으면 원본을 그대로 반환
*/
public static String stripControlChars(String s) {
if (s == null || s.isEmpty()) {
return s;
}
// 빠른 경로: 제어문자가 없으면 원본 그대로 (대부분의 값이 여기 해당)
boolean found = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 || c == 0x7F) {
found = true;
break;
}
}
if (!found) {
return s;
}
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\t' || c == '\n' || c == '\r') {
sb.append(' ');
} else if (c < 0x20 || c == 0x7F) {
continue;
} else {
sb.append(c);
}
}
return sb.toString();
}
// ASCII Bytes에서 특정길이의 값을 추출하는 Method // ASCII Bytes에서 특정길이의 값을 추출하는 Method
public static String getAscBytes(byte[] message, int startPos, int length) { public static String getAscBytes(byte[] message, int startPos, int length) {
if (message == null || message.length < startPos) { if (message == null || message.length < startPos) {