From 1994e0ebd07769264c250a4be50281a41ed5444e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 7 Jul 2026 21:44:07 +0200 Subject: [PATCH] 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 --- .../view/ViewResolutionResultHandler.java | 26 ++++++++++++++++++- ...gmentViewResolutionResultHandlerTests.java | 15 +++++++++++ ...ResponseBodyEmitterReturnValueHandler.java | 23 ++++++++++++++-- .../FragmentRenderingStreamTests.java | 25 ++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java index 5147d2c59c6..9c11846c9c3 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -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(); + } } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java index 7a9ff80abbf..db95230fc13 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java @@ -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:

+ data: Hello Foo + data: and Bar + data:

+ + """); + } + @Test void renderServerSentEventFragmentStream() { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java index dd038afa785..1c5cd5dd2a3 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java @@ -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); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/FragmentRenderingStreamTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/FragmentRenderingStreamTests.java index a5fec8bd397..f478dd9dfa9 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/FragmentRenderingStreamTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/FragmentRenderingStreamTests.java @@ -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:

+ data: Hello Foo + data: and Bar + data:

+ + """)); + } + @SuppressWarnings({"unused", "DataFlowIssue"}) private static class TestController {