From 6dee34e0858f377925becf0f868312df9ae9caa2 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 28 Aug 2026 14:28:01 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EC=96=B4=EB=AC=B8=EC=9E=90=20?= =?UTF-8?q?=EC=84=9E=EC=9D=B8=20=EC=A0=84=EB=AC=B8=20=EB=8C=80=EC=9D=91:?= =?UTF-8?q?=20=EC=88=98=EC=8B=A0=EC=9D=80=20=EC=A0=95=EA=B7=9C=ED=99=94,?= =?UTF-8?q?=20=EC=86=A1=EC=8B=A0=EC=9D=80=20=EC=A0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신측 - 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 전수 + 로그 재현 케이스 통과. --- .../TemplateAdapterErrorMsgHandler.java | 9 ++- ...lateCodeConvertAdapterErrorMsgHandler.java | 4 +- .../impl/HttpClient5AdapterServiceBody.java | 2 +- .../filter/custom/JsonToSetStatusFilter.java | 3 +- .../custom/JsonToStdConverterFilter.java | 2 +- .../custom/KbankEaiJsonParseFilter.java | 4 +- .../custom/KbankHmacSha256VerifyFilter.java | 3 +- .../eactive/eai/common/util/JacksonUtil.java | 4 +- .../eactive/eai/common/util/MessageUtil.java | 55 +++++++++++++++++++ 9 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java index a12e5da..5993b53 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -23,6 +23,7 @@ import com.eactive.eai.common.message.EAIMessage; import com.eactive.eai.common.property.PropManager; 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.message.StandardItem; import com.eactive.eai.message.StandardMessage; import com.google.gson.JsonElement; @@ -171,7 +172,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle } else { // ${path} / ${callprop.키} / ${exception.필드} 스칼라 변수 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)); } @@ -400,7 +404,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle String value = ""; StandardItem item = row.get(fieldName); if (item != null) { - value = StringUtils.defaultString(item.getValue()); + // render() 의 스칼라 치환과 동일한 이유로 제어문자를 걸러낸다 + value = MessageUtil.stripControlChars(StringUtils.defaultString(item.getValue())); } varMatcher.appendReplacement(result, Matcher.quoteReplacement(value)); } diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java index 43a32c5..56d5e1e 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java @@ -60,7 +60,9 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr return responseMsessage; } - JsonNode rootNode = OBJECT_MAPPER.readTree(jsonStr); + // 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다. + // (이미 표준을 지킨 JSON 이면 escapeControlChars 는 원본을 그대로 반환한다) + JsonNode rootNode = OBJECT_MAPPER.readTree(JacksonUtil.escapeControlChars(jsonStr)); boolean modified = false; for (String rawField : fieldsValue.split(",")) { diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java index a651da0..845e7e0 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java @@ -64,7 +64,7 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp // 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다. // 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다. 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"); if( headerPart.get("mciIntfId") != null && headerPart.get("mciIntfId").asText().trim().length() > 0 ) { diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToSetStatusFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToSetStatusFilter.java index 678af70..944f169 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToSetStatusFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToSetStatusFilter.java @@ -6,6 +6,7 @@ import java.util.Properties; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import com.eactive.eai.common.util.JacksonUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import org.apache.commons.lang3.StringUtils; @@ -66,6 +67,6 @@ public class JsonToSetStatusFilter implements HttpAdapterFilter { orgMessageString = (String) message; } - return mapper.readTree(orgMessageString); + return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString)); } } diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToStdConverterFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToStdConverterFilter.java index f02e309..6328641 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToStdConverterFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/JsonToStdConverterFilter.java @@ -107,7 +107,7 @@ public class JsonToStdConverterFilter implements HttpAdapterFilter { orgMessageString = (String) message; } - return mapper.readTree(orgMessageString); + return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString)); } } diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankEaiJsonParseFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankEaiJsonParseFilter.java index b1d0027..15f021c 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankEaiJsonParseFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankEaiJsonParseFilter.java @@ -33,7 +33,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter { for(Iterator it = rootNode.fieldNames(); it.hasNext();) { String fieldName = it.next(); String value = rootNode.get(fieldName).asText(); - JsonNode jsonNode = mapper.readTree(value); + JsonNode jsonNode = mapper.readTree(JacksonUtil.escapeControlChars(value)); replacedJson.set(fieldName, jsonNode); } @@ -65,7 +65,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter { orgMessageString = (String) message; } - return mapper.readTree(orgMessageString); + return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString)); } } diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankHmacSha256VerifyFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankHmacSha256VerifyFilter.java index e68cb90..6e56f4e 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankHmacSha256VerifyFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/custom/KbankHmacSha256VerifyFilter.java @@ -16,6 +16,7 @@ import javax.servlet.http.HttpServletResponse; import com.eactive.eai.adapter.AdapterManager; import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.util.JacksonUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; @@ -203,7 +204,7 @@ public class KbankHmacSha256VerifyFilter implements HttpAdapterFilter { orgMessageString = (String) message; } - return mapper.readTree(orgMessageString); + return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString)); } @Override diff --git a/src/main/java/com/eactive/eai/common/util/JacksonUtil.java b/src/main/java/com/eactive/eai/common/util/JacksonUtil.java index f1d0756..9b08048 100644 --- a/src/main/java/com/eactive/eai/common/util/JacksonUtil.java +++ b/src/main/java/com/eactive/eai/common/util/JacksonUtil.java @@ -191,7 +191,9 @@ public final class JacksonUtil { return null; } - return objectMapper.readTree(jsonStr); + // 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다. + // 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다. + return objectMapper.readTree(escapeControlChars(jsonStr)); } public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException { diff --git a/src/main/java/com/eactive/eai/common/util/MessageUtil.java b/src/main/java/com/eactive/eai/common/util/MessageUtil.java index 4bf4423..0bc5286 100644 --- a/src/main/java/com/eactive/eai/common/util/MessageUtil.java +++ b/src/main/java/com/eactive/eai/common/util/MessageUtil.java @@ -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 public static String getAscBytes(byte[] message, int startPos, int length) { if (message == null || message.length < startPos) {