제어문자 섞인 전문 대응: 수신은 정규화, 송신은 정제
수신측 - 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:
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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(",")) {
|
||||||
|
|||||||
+1
-1
@@ -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 ) {
|
||||||
|
|||||||
+2
-1
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user