Guard against invalid id/event values in Server Sent Events

Prior to this commit, our implementation of Server Sent Events (SSE),
`SseEmitter` (MVC) and `ServerSentEvent` (WebFlux), would not guard
against invalid characters if the application mistakenly inserts such
characters in the `id` or `event` types.
Both implementations would also behave differently when it comes
to escaping comment multi-line events.

This commit ensures that both implementations handle multi-line comment
events and reject invalid characters in id/event types.
This commit also optimizes `String` concatenation and memory usage
when writing data.

Fixes gh-36440
This commit is contained in:
Brian Clozel
2026-03-10 17:30:24 +01:00
parent 37e8aa76e9
commit 6e9758700a
6 changed files with 183 additions and 25 deletions
@@ -20,6 +20,7 @@ import java.time.Duration;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
@@ -239,16 +240,23 @@ public final class ServerSentEvent<T> {
@Override
public Builder<T> id(String id) {
checkEvent(id);
this.id = id;
return this;
}
@Override
public Builder<T> event(String event) {
checkEvent(event);
this.event = event;
return this;
}
private static void checkEvent(String content) {
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
"illegal character '\\n' or '\\r' in event content");
}
@Override
public Builder<T> retry(Duration retry) {
this.retry = retry;
@@ -40,7 +40,6 @@ import org.springframework.http.ReactiveHttpOutputMessage;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@code HttpMessageWriter} for {@code "text/event-stream"} responses.
@@ -48,6 +47,7 @@ import org.springframework.util.StringUtils;
* @author Sebastien Deleuze
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 5.0
*/
public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Object> {
@@ -129,8 +129,9 @@ public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Objec
result = Flux.just(encodeText(sseText + "\n", mediaType, factory));
}
else if (data instanceof String text) {
text = StringUtils.replace(text, "\n", "\ndata:");
result = Flux.just(encodeText(sseText + text + "\n\n", mediaType, factory));
StringBuilder sb = new StringBuilder(sseText);
writeStringData(text, sb);
result = Flux.just(encodeText(sb.toString(), mediaType, factory));
}
else {
result = encodeEvent(sseText, data, dataType, mediaType, factory, hints);
@@ -140,6 +141,31 @@ public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Objec
});
}
private void writeStringData(String input, 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("\ndata:");
}
else if (c == '\n') {
sb.append("\ndata:");
}
else {
sb.append(c);
}
}
}
sb.append("\n\n");
}
@SuppressWarnings("unchecked")
private <T> Flux<DataBuffer> encodeEvent(CharSequence sseText, T data, ResolvableType dataType,
MediaType mediaType, DataBufferFactory factory, Map<String, Object> hints) {
@@ -110,12 +110,13 @@ class ServerSentEventHttpMessageWriterTests extends AbstractDataBufferAllocating
super.bufferFactory = bufferFactory;
MockServerHttpResponse outputMessage = new MockServerHttpResponse(super.bufferFactory);
Flux<String> source = Flux.just("foo\nbar", "foo\nbaz");
Flux<String> source = Flux.just("first\nsecond", "first\rsecond", "first\r\nsecond");
testWrite(source, outputMessage, String.class);
StepVerifier.create(outputMessage.getBody())
.consumeNextWith(stringConsumer("data:foo\ndata:bar\n\n"))
.consumeNextWith(stringConsumer("data:foo\ndata:baz\n\n"))
.consumeNextWith(stringConsumer("data:first\ndata:second\n\n"))
.consumeNextWith(stringConsumer("data:first\ndata:second\n\n"))
.consumeNextWith(stringConsumer("data:first\ndata:second\n\n"))
.expectComplete()
.verify();
}
@@ -0,0 +1,55 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.codec;
import java.util.stream.Stream;
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.assertThatIllegalArgumentException;
/**
* Tests for {@link ServerSentEvent}.
* @author Brian Clozel
*/
class ServerSentEventTests {
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void rejectsInvalidId(String newLine, String description) {
assertThatIllegalArgumentException().isThrownBy(() ->
ServerSentEvent.<String>builder().id("first" + newLine + "second").build());
}
@ParameterizedTest(name = "{1}")
@MethodSource("newLineCharacters")
void rejectsInvalidEvent(String newLine, String description) {
assertThatIllegalArgumentException().isThrownBy(() ->
ServerSentEvent.<String>builder().event("first" + newLine + "second").build());
}
private static Stream<Arguments> newLineCharacters() {
return Stream.of(
Arguments.of("\n", "LF"),
Arguments.of("\r", "CR"),
Arguments.of("\r\n", "CRLF")
);
}
}