Escape SSE view fragments

Prior to this commit, the MVC and WebFlux view fragments rendering would
only partially escape rendered view fragments before sending then as SSE
events. This could in some cases break the SSE stream with invalid data.

This commit ensures that the rendered views are properly escaped before
they are sent as SSE events.

Fixes gh-37061
This commit is contained in:
Brian Clozel
2026-08-14 09:11:50 +02:00
parent dc7fc89aec
commit 1994e0ebd0
4 changed files with 86 additions and 3 deletions
@@ -603,7 +603,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
finally {
DataBufferUtils.release(buffer);
}
text = text.replace("\n", "\ndata:");
text = escapeSseFragment(text);
return bufferFactory.wrap(text.getBytes(charset));
});
@@ -614,6 +614,30 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
byte[] bytes = text.getBytes(charset);
return bufferFactory.wrap(bytes);
}
private String escapeSseFragment(String content) {
if (content.indexOf('\n') == -1 && content.indexOf('\r') == -1) {
return content;
}
StringBuilder fragment = new StringBuilder();
int length = content.length();
for (int i = 0; i < length; i++) {
char c = content.charAt(i);
if (c == '\r') {
if (i + 1 < length && content.charAt(i + 1) == '\n') {
i++;
}
fragment.append("\ndata:");
}
else if (c == '\n') {
fragment.append("\ndata:");
}
else {
fragment.append(c);
}
}
return fragment.toString();
}
}
}
@@ -119,6 +119,21 @@ class FragmentViewResolutionResultHandlerTests {
""");
}
@Test
void escapeViewFragment() {
Fragment fragment = Fragment.create("fragment1", Map.of("foo", "Foo\n and Bar"));
testSse(Flux.just(fragment),
on(Handler.class).resolveReturnType(Flux.class, Fragment.class),
"""
event:fragment1
data:<p>
data: Hello Foo
data: and Bar
data:</p>
""");
}
@Test
void renderServerSentEventFragmentStream() {
@@ -476,8 +476,27 @@ public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodRetur
public byte[] getFragmentContent() {
this.writer.flush();
String content = this.outputStream.toString(this.charset);
content = content.replace("\n", "\ndata:");
return content.getBytes(this.charset);
if (content.indexOf('\n') == -1 && content.indexOf('\r') == -1) {
return content.getBytes(this.charset);
}
StringBuilder fragment = new StringBuilder();
int length = content.length();
for (int i = 0; i < length; i++) {
char c = content.charAt(i);
if (c == '\r') {
if (i + 1 < length && content.charAt(i + 1) == '\n') {
i++;
}
fragment.append("\ndata:");
}
else if (c == '\n') {
fragment.append("\ndata:");
}
else {
fragment.append(c);
}
}
return fragment.toString().getBytes(this.charset);
}
}
@@ -146,6 +146,31 @@ class FragmentRenderingStreamTests {
"""));
}
@Test
void escapeViewFragment() throws Exception {
MethodParameter type = on(TestController.class).resolveReturnType(SseEmitter.class);
SseEmitter emitter = new SseEmitter();
this.handler.handleReturnValue(emitter, type, new ModelAndViewContainer(), webRequest);
assertThat(this.request.isAsyncStarted()).isTrue();
assertThat(this.response.getStatus()).isEqualTo(200);
ModelAndView mav1 = new ModelAndView("fragment1", Map.of("foo", "Foo\n and Bar"));
emitter.send(SseEmitter.event().data(mav1));
assertThat(this.response.getContentType()).isEqualTo("text/event-stream");
assertThat(this.response.getContentAsString()).isEqualTo(("""
event:fragment1
data:<p>
data: Hello Foo
data: and Bar
data:</p>
"""));
}
@SuppressWarnings({"unused", "DataFlowIssue"})
private static class TestController {