From 86a68a77c40bf33711c1bdb25296a12834791048 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 3 Jun 2026 11:13:32 +0200 Subject: [PATCH] Support multi-line comments in Server Sent Events Prior to this commit, comments sent with Server Sent Events could break the wire format when sent over the network when comments contained line breaks. While comments are mainly used for sending keepalive messages, they can also be used for sending debug data. This commit ensures that line breaks are properly handled in comments. Fixes gh-36866 --- .../http/codec/ServerSentEvent.java | 29 +++++++++++++++++-- .../http/codec/ServerSentEventTests.java | 10 +++++++ .../mvc/method/annotation/SseEmitter.java | 21 ++++++++++---- .../method/annotation/SseEmitterTests.java | 11 +++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java index c913a16ce82..f2af79ab2ca 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEvent.java @@ -22,7 +22,6 @@ import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; /** * Representation for a Server-Sent Event for use with Spring's reactive Web support. @@ -112,7 +111,9 @@ public final class ServerSentEvent { appendAttribute("retry", this.retry.toMillis(), sb); } if (this.comment != null) { - sb.append(':').append(StringUtils.replace(this.comment, "\n", "\n:")).append('\n'); + sb.append(':'); + appendEscaped(this.comment, "\n:", sb); + sb.append('\n'); } if (this.data != null) { sb.append("data:"); @@ -124,6 +125,30 @@ public final class ServerSentEvent { sb.append(fieldName).append(':').append(fieldValue).append('\n'); } + private void appendEscaped(String input, String replacement, StringBuilder sb) { + if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { + sb.append(input); + } + else { + int length = input.length(); + for (int i = 0; i < length; i++) { + char c = input.charAt(i); + if (c == '\r') { + if (i + 1 < length && input.charAt(i + 1) == '\n') { + i++; + } + sb.append(replacement); + } + else if (c == '\n') { + sb.append(replacement); + } + else { + sb.append(c); + } + } + } + } + @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof ServerSentEvent that && diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java index a106ea07337..9e1a1d7c76f 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** @@ -44,6 +45,15 @@ class ServerSentEventTests { ServerSentEvent.builder().event("first" + newLine + "second").build()); } + + @ParameterizedTest(name = "{1}") + @MethodSource("newLineCharacters") + void supportMultiLineComments(String newLine, String description) { + ServerSentEvent event = ServerSentEvent.builder() + .comment("foo" + newLine + "bar" + newLine + "baz").data("payload").build(); + assertThat(event.format()).isEqualTo(":foo\n:bar\n:baz\ndata:"); + } + private static Stream newLineCharacters() { return Stream.of( Arguments.of("\n", "LF"), diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java index c302db9f830..f0bbe9cd9b4 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java @@ -224,7 +224,9 @@ public class SseEmitter extends ResponseBodyEmitter { @Override public SseEventBuilder comment(String comment) { - append(':').append(StringUtils.replace(comment, "\n", "\n:")).append('\n'); + append(':'); + appendEscaped(comment, "\n:"); + append('\n'); return this; } @@ -259,6 +261,16 @@ public class SseEmitter extends ResponseBodyEmitter { if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { this.dataToSend.add(new DataWithMediaType(input, mediaType)); } + else { + appendEscaped(input, "\ndata:"); + saveAppendedText(mediaType); + } + } + + private void appendEscaped(String input, String replacement) { + if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) { + append(input); + } else { int length = input.length(); for (int i = 0; i < length; i++) { @@ -267,16 +279,15 @@ public class SseEmitter extends ResponseBodyEmitter { if (i + 1 < length && input.charAt(i + 1) == '\n') { i++; } - this.sb.append("\ndata:"); + append(replacement); } else if (c == '\n') { - this.sb.append("\ndata:"); + append(replacement); } else { - this.sb.append(c); + append(c); } } - saveAppendedText(mediaType); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java index 1b4d8ecaeb9..2ff5bfebd50 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitterTests.java @@ -168,6 +168,17 @@ class SseEmitterTests { .send(event().name("first" + newLineChars + "second"))); } + @ParameterizedTest(name = "{1}") + @MethodSource("newLineCharacters") + void supportMultiLineComments(String newLineChars, String description) throws Exception { + this.emitter.send(event().comment("foo" + newLineChars + "bar" + newLineChars + "baz").data("payload")); + this.handler.assertSentObjectCount(3); + this.handler.assertObject(0, ":foo\n:bar\n:baz\ndata:", TEXT_PLAIN_UTF8); + this.handler.assertObject(1, "payload"); + this.handler.assertObject(2, "\n\n", TEXT_PLAIN_UTF8); + this.handler.assertWriteCount(1); + } + private static Stream newLineCharacters() { return Stream.of( Arguments.of("\n", "LF"),