From 2ae138c7012ced9f888cddea02cffb81c9c801bd Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Mon, 1 Jun 2026 11:46:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:#198=20=EB=AC=B8=EC=84=9C=20NUL=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=A0=95=EC=A0=9C=20=EB=B0=8F=20Discord?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/CreateDocumentBlockRequest.java | 9 ++++- .../request/CreateDocumentRequest.java | 4 +- .../request/RequestStringSanitizer.java | 14 +++++++ .../request/UpdateDocumentRequest.java | 4 +- .../CreateDocumentRequestValidationTest.java | 23 +++++++++++ .../alert/discord/DiscordWebhookNotifier.java | 16 +++++++- .../discord/DiscordWebhookNotifierTest.java | 40 +++++++++++++++++++ 7 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java create mode 100644 ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java index 6efb9985..de24370f 100644 --- a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java @@ -37,6 +37,13 @@ public boolean isValidBlockStructure() { } public DocumentBlockCommand toCommand() { - return new DocumentBlockCommand(resolvedType(), content, role, blockId, blobKey, url); + return new DocumentBlockCommand( + resolvedType(), + RequestStringSanitizer.stripNullChar(content), + RequestStringSanitizer.stripNullChar(role), + blockId, + RequestStringSanitizer.stripNullChar(blobKey), + RequestStringSanitizer.stripNullChar(url) + ); } } diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentRequest.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentRequest.java index d80d7d37..0eb07967 100644 --- a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentRequest.java +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentRequest.java @@ -21,8 +21,8 @@ public record CreateDocumentRequest( ) { public CreateDocumentCommand toCommand() { return new CreateDocumentCommand( - title, - text, + RequestStringSanitizer.stripNullChar(title), + RequestStringSanitizer.stripNullChar(text), paragraphs == null ? null : paragraphs.stream().map(CreateDocumentBlockRequest::toCommand).toList(), folderId, purpose diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java new file mode 100644 index 00000000..77ccd660 --- /dev/null +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java @@ -0,0 +1,14 @@ +package or.hyu.ssd.api.document.request; + +final class RequestStringSanitizer { + + private RequestStringSanitizer() { + } + + static String stripNullChar(String value) { + if (value == null || value.indexOf('\u0000') < 0) { + return value; + } + return value.replace("\u0000", ""); + } +} diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/UpdateDocumentRequest.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/UpdateDocumentRequest.java index c4a6c082..f1250fad 100644 --- a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/UpdateDocumentRequest.java +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/UpdateDocumentRequest.java @@ -14,8 +14,8 @@ public record UpdateDocumentRequest( ) { public UpdateDocumentCommand toCommand() { return new UpdateDocumentCommand( - title, - text, + RequestStringSanitizer.stripNullChar(title), + RequestStringSanitizer.stripNullChar(text), paragraphs == null ? null : paragraphs.stream().map(CreateDocumentBlockRequest::toCommand).toList() ); } diff --git a/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java b/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java index 6e13e2fb..765d6490 100644 --- a/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java +++ b/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java @@ -64,6 +64,29 @@ void purposeRejectsNull() { .contains("문서 목적은 필수입니다"); } + @Test + @DisplayName("toCommand는 문자열의 NUL 문자(\\u0000)를 제거한다") + void toCommandStripsNullCharacter() { + // given + CreateDocumentRequest request = new CreateDocumentRequest( + "제목\u0000", + "본문\u0000텍스트", + Arrays.asList(new CreateDocumentBlockRequest(null, "문단\u0000내용", "\u0000##", 1, null, null)), + 0L, + DocumentPurpose.WRITING + ); + + // when + var command = request.toCommand(); + + // then + assertThat(command.title()).isEqualTo("제목"); + assertThat(command.text()).isEqualTo("본문텍스트"); + assertThat(command.blocks()).hasSize(1); + assertThat(command.blocks().getFirst().content()).isEqualTo("문단내용"); + assertThat(command.blocks().getFirst().role()).isEqualTo("##"); + } + private static Set extractMessages(Set> violations) { return violations.stream() .map(ConstraintViolation::getMessage) diff --git a/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java b/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java index cc6b143a..9151e05f 100644 --- a/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java +++ b/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java @@ -26,6 +26,8 @@ public class DiscordWebhookNotifier implements ErrorAlertNotifier { private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int DISCORD_CONTENT_LIMIT = 2000; + private static final String TRUNCATION_SUFFIX = "\n...(truncated)"; private final DiscordProperties discordProperties; private final Environment environment; @@ -120,7 +122,7 @@ private String resolveEnvironment() { } private String buildPayload(String content) { - return "{\"content\":\"" + escapeJson(content) + "\"}"; + return "{\"content\":\"" + escapeJson(truncateForDiscord(content)) + "\"}"; } private String escapeJson(String value) { @@ -130,4 +132,16 @@ private String escapeJson(String value) { .replace("\r", "\\r") .replace("\n", "\\n"); } + + private String truncateForDiscord(String content) { + if (content == null || content.length() <= DISCORD_CONTENT_LIMIT) { + return content; + } + + int maxPrefixLength = DISCORD_CONTENT_LIMIT - TRUNCATION_SUFFIX.length(); + if (maxPrefixLength <= 0) { + return content.substring(0, DISCORD_CONTENT_LIMIT); + } + return content.substring(0, maxPrefixLength) + TRUNCATION_SUFFIX; + } } diff --git a/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java new file mode 100644 index 00000000..ce93bb1e --- /dev/null +++ b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java @@ -0,0 +1,40 @@ +package or.hyu.ssd.external.alert.discord; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; +import or.hyu.ssd.external.config.DiscordProperties; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscordWebhookNotifierTest { + + @Test + @DisplayName("디스코드 본문이 2000자를 넘으면 잘라낸다") + void truncateMessageWhenContentTooLong() throws Exception { + DiscordWebhookNotifier notifier = new DiscordWebhookNotifier(new DiscordProperties(), new MockEnvironment()); + Method truncateMethod = DiscordWebhookNotifier.class.getDeclaredMethod("truncateForDiscord", String.class); + truncateMethod.setAccessible(true); + + String longMessage = "a".repeat(2100); + String truncated = (String) truncateMethod.invoke(notifier, longMessage); + + assertThat(truncated.length()).isLessThanOrEqualTo(2000); + assertThat(truncated).endsWith("\n...(truncated)"); + } + + @Test + @DisplayName("디스코드 본문이 2000자 이하면 그대로 둔다") + void keepMessageWhenWithinLimit() throws Exception { + DiscordWebhookNotifier notifier = new DiscordWebhookNotifier(new DiscordProperties(), new MockEnvironment()); + Method truncateMethod = DiscordWebhookNotifier.class.getDeclaredMethod("truncateForDiscord", String.class); + truncateMethod.setAccessible(true); + + String message = "a".repeat(2000); + String truncated = (String) truncateMethod.invoke(notifier, message); + + assertThat(truncated).isEqualTo(message); + } +} From b753cd7e0b023bab22f3d40aea744ac558bd7b9d Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Mon, 1 Jun 2026 11:51:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:#198=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A3=BC=EC=84=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alert/discord/DiscordWebhookNotifierTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java index ce93bb1e..76493298 100644 --- a/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java +++ b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java @@ -14,13 +14,16 @@ class DiscordWebhookNotifierTest { @Test @DisplayName("디스코드 본문이 2000자를 넘으면 잘라낸다") void truncateMessageWhenContentTooLong() throws Exception { + // given DiscordWebhookNotifier notifier = new DiscordWebhookNotifier(new DiscordProperties(), new MockEnvironment()); Method truncateMethod = DiscordWebhookNotifier.class.getDeclaredMethod("truncateForDiscord", String.class); truncateMethod.setAccessible(true); - String longMessage = "a".repeat(2100); + + // when String truncated = (String) truncateMethod.invoke(notifier, longMessage); + // then assertThat(truncated.length()).isLessThanOrEqualTo(2000); assertThat(truncated).endsWith("\n...(truncated)"); } @@ -28,13 +31,16 @@ void truncateMessageWhenContentTooLong() throws Exception { @Test @DisplayName("디스코드 본문이 2000자 이하면 그대로 둔다") void keepMessageWhenWithinLimit() throws Exception { + // given DiscordWebhookNotifier notifier = new DiscordWebhookNotifier(new DiscordProperties(), new MockEnvironment()); Method truncateMethod = DiscordWebhookNotifier.class.getDeclaredMethod("truncateForDiscord", String.class); truncateMethod.setAccessible(true); - String message = "a".repeat(2000); + + // when String truncated = (String) truncateMethod.invoke(notifier, message); + // then assertThat(truncated).isEqualTo(message); } } From 08bd8425da1f3288e4c3f2a326bca562e8ad315e Mon Sep 17 00:00:00 2001 From: gdbs1107 Date: Mon, 1 Jun 2026 11:56:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:#198=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/CreateDocumentBlockRequest.java | 8 +++++--- .../request/RequestStringSanitizer.java | 2 +- .../CreateDocumentRequestValidationTest.java | 19 +++++++++++++++++++ .../alert/discord/DiscordWebhookNotifier.java | 5 ++++- .../discord/DiscordWebhookNotifierTest.java | 16 ++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java index de24370f..ff86d9b1 100644 --- a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/CreateDocumentBlockRequest.java @@ -23,16 +23,18 @@ public DocumentBlockType resolvedType() { @AssertTrue(message = "문단 블록은 content와 role이 필요하고, 이미지 블록은 blobKey 또는 url이 필요합니다") public boolean isValidBlockStructure() { + String sanitizedRole = RequestStringSanitizer.stripNullChar(role); + if (resolvedType().isParagraph()) { return StringUtils.hasText(content) - && role != null - && ROLE_PATTERN.matcher(role).matches() + && sanitizedRole != null + && ROLE_PATTERN.matcher(sanitizedRole).matches() && !StringUtils.hasText(blobKey) && !StringUtils.hasText(url); } return !StringUtils.hasText(content) - && !StringUtils.hasText(role) + && !StringUtils.hasText(sanitizedRole) && (StringUtils.hasText(blobKey) || StringUtils.hasText(url)); } diff --git a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java index 77ccd660..38a2a4a0 100644 --- a/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java +++ b/ssd-api/src/main/java/or/hyu/ssd/api/document/request/RequestStringSanitizer.java @@ -6,7 +6,7 @@ private RequestStringSanitizer() { } static String stripNullChar(String value) { - if (value == null || value.indexOf('\u0000') < 0) { + if (value == null || !value.contains("\u0000")) { return value; } return value.replace("\u0000", ""); diff --git a/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java b/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java index 765d6490..71504f42 100644 --- a/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java +++ b/ssd-api/src/test/java/or/hyu/ssd/api/document/request/CreateDocumentRequestValidationTest.java @@ -87,6 +87,25 @@ void toCommandStripsNullCharacter() { assertThat(command.blocks().getFirst().role()).isEqualTo("##"); } + @Test + @DisplayName("검증 단계에서도 NUL 문자가 포함된 role을 허용한다") + void validationAllowsRoleContainingNullChar() { + // given + CreateDocumentRequest request = new CreateDocumentRequest( + "제목", + "본문", + Arrays.asList(new CreateDocumentBlockRequest(null, "문단", "\u0000##", 1, null, null)), + 0L, + DocumentPurpose.WRITING + ); + + // when + Set> violations = VALIDATOR.validate(request); + + // then + assertThat(violations).isEmpty(); + } + private static Set extractMessages(Set> violations) { return violations.stream() .map(ConstraintViolation::getMessage) diff --git a/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java b/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java index 9151e05f..9507bac6 100644 --- a/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java +++ b/ssd-external/src/main/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifier.java @@ -134,7 +134,10 @@ private String escapeJson(String value) { } private String truncateForDiscord(String content) { - if (content == null || content.length() <= DISCORD_CONTENT_LIMIT) { + if (content == null) { + return ""; + } + if (content.length() <= DISCORD_CONTENT_LIMIT) { return content; } diff --git a/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java index 76493298..929c4a6b 100644 --- a/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java +++ b/ssd-external/src/test/java/or/hyu/ssd/external/alert/discord/DiscordWebhookNotifierTest.java @@ -26,6 +26,7 @@ void truncateMessageWhenContentTooLong() throws Exception { // then assertThat(truncated.length()).isLessThanOrEqualTo(2000); assertThat(truncated).endsWith("\n...(truncated)"); + assertThat(truncated).startsWith(longMessage.substring(0, 1985)); } @Test @@ -43,4 +44,19 @@ void keepMessageWhenWithinLimit() throws Exception { // then assertThat(truncated).isEqualTo(message); } + + @Test + @DisplayName("디스코드 본문이 null이면 빈 문자열을 반환한다") + void returnEmptyStringWhenContentIsNull() throws Exception { + // given + DiscordWebhookNotifier notifier = new DiscordWebhookNotifier(new DiscordProperties(), new MockEnvironment()); + Method truncateMethod = DiscordWebhookNotifier.class.getDeclaredMethod("truncateForDiscord", String.class); + truncateMethod.setAccessible(true); + + // when + String truncated = (String) truncateMethod.invoke(notifier, (String) null); + + // then + assertThat(truncated).isEqualTo(""); + } }