Bug Fix: SSE Events lines MUST NOT contain \r (#5868)
diff --git a/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java b/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
index d2e7b85..12620b2 100644
--- a/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
+++ b/media/sse/src/main/java/org/glassfish/jersey/media/sse/OutboundEventWriter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2012, 2025 Oracle and/or its affiliates. All rights reserved.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0, which is available at
@@ -16,11 +16,15 @@
package org.glassfish.jersey.media.sse;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
import java.io.IOException;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
+import java.util.Objects;
+import java.util.regex.Pattern;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
@@ -43,15 +47,14 @@
*/
class OutboundEventWriter implements MessageBodyWriter<OutboundSseEvent> {
- private static final Charset UTF8 = Charset.forName("UTF-8");
-
// encoding does not matter (lower ASCII characters)
- private static final byte[] COMMENT_LEAD = ": ".getBytes(UTF8);
- private static final byte[] NAME_LEAD = "event: ".getBytes(UTF8);
- private static final byte[] ID_LEAD = "id: ".getBytes(UTF8);
- private static final byte[] RETRY_LEAD = "retry: ".getBytes(UTF8);
- private static final byte[] DATA_LEAD = "data: ".getBytes(UTF8);
+ private static final byte[] COMMENT_LEAD = ": ".getBytes(UTF_8);
+ private static final byte[] NAME_LEAD = "event: ".getBytes(UTF_8);
+ private static final byte[] ID_LEAD = "id: ".getBytes(UTF_8);
+ private static final byte[] RETRY_LEAD = "retry: ".getBytes(UTF_8);
+ private static final byte[] DATA_LEAD = "data: ".getBytes(UTF_8);
private static final byte[] EOL = {'\n'};
+ private static final Pattern EOL_PATTERN = Pattern.compile("\r\n|\r|\n");
private final Provider<MessageBodyWorkers> workersProvider;
@@ -87,7 +90,7 @@
final Charset charset = MessageUtils.getCharset(mediaType);
if (outboundEvent.getComment() != null) {
- for (final String comment : outboundEvent.getComment().split("\n")) {
+ for (final String comment : EOL_PATTERN.split(outboundEvent.getComment())) {
entityStream.write(COMMENT_LEAD);
entityStream.write(comment.getBytes(charset));
entityStream.write(EOL);
@@ -97,12 +100,12 @@
if (outboundEvent.getType() != null) {
if (outboundEvent.getName() != null) {
entityStream.write(NAME_LEAD);
- entityStream.write(outboundEvent.getName().getBytes(charset));
+ entityStream.write(outboundEvent.getName().replace("\r", "").replace("\n", "").getBytes(charset));
entityStream.write(EOL);
}
if (outboundEvent.getId() != null) {
entityStream.write(ID_LEAD);
- entityStream.write(outboundEvent.getId().getBytes(charset));
+ entityStream.write(outboundEvent.getId().replace("\r", "").replace("\n", "").getBytes(charset));
entityStream.write(EOL);
}
if (outboundEvent.getReconnectDelay() > SseFeature.RECONNECT_NOT_SET) {
@@ -115,6 +118,7 @@
outboundEvent.getMediaType() == null ? MediaType.TEXT_PLAIN_TYPE : outboundEvent.getMediaType();
final MessageBodyWriter messageBodyWriter = workersProvider.get().getMessageBodyWriter(outboundEvent.getType(),
outboundEvent.getGenericType(), annotations, eventMediaType);
+ final var dataLeadStream = new DataLeadStream(entityStream);
messageBodyWriter.writeTo(
outboundEvent.getData(),
outboundEvent.getType(),
@@ -122,23 +126,74 @@
annotations,
eventMediaType,
httpHeaders,
- new OutputStream() {
-
- private boolean start = true;
-
- @Override
- public void write(final int i) throws IOException {
- if (start) {
- entityStream.write(DATA_LEAD);
- start = false;
- }
- entityStream.write(i);
- if (i == '\n') {
- entityStream.write(DATA_LEAD);
- }
- }
- });
+ dataLeadStream);
+ dataLeadStream.finish();
entityStream.write(EOL);
}
}
+
+ static final class DataLeadStream extends OutputStream {
+ private final OutputStream entityStream;
+
+ private int lastChar = -1;
+
+ DataLeadStream(final OutputStream entityStream) {
+ this.entityStream = entityStream;
+ }
+
+ @Override
+ public void write(final int i) throws IOException {
+ if (lastChar == -1) {
+ entityStream.write(DATA_LEAD);
+ } else if (lastChar != '\n' && lastChar != '\r') {
+ entityStream.write(lastChar);
+ } else if (lastChar == '\n' || lastChar == '\r' && i != '\n') {
+ entityStream.write(EOL);
+ entityStream.write(DATA_LEAD);
+ }
+
+ lastChar = i;
+ }
+
+ private static int indexOfEol(final byte[] b, final int fromIndex, final int toIndex) {
+ for (var i = fromIndex; i < toIndex; i++) {
+ if (b[i] == '\n' || b[i] == '\r') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws IOException {
+ Objects.checkFromIndexSize(off, len, b.length);
+ if (len == 0) {
+ return;
+ }
+ write(b[off]);
+ if (len > 1) {
+ final var end = off + len - 1;
+ var i = off;
+ for (var j = indexOfEol(b, i, end); j != -1; j = indexOfEol(b, i, end)) {
+ entityStream.write(b, i, j - i);
+ entityStream.write(EOL);
+ entityStream.write(DATA_LEAD);
+ if (b[j] == '\r' && b[j + 1] == '\n') {
+ j++;
+ }
+ i = ++j;
+ }
+ if (i < end) {
+ entityStream.write(b, i, end - i);
+ }
+ lastChar = b[end];
+ }
+ }
+
+ void finish() throws IOException {
+ if (lastChar != -1) {
+ write(-1);
+ }
+ }
+ }
}
diff --git a/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java b/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java
new file mode 100644
index 0000000..b27402c
--- /dev/null
+++ b/media/sse/src/test/java/org/glassfish/jersey/media/sse/DataLeadStreamTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 Markus KARG
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.glassfish.jersey.media.sse;
+
+import java.io.ByteArrayOutputStream;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Basic set of unit tests for {@link DataLeadStream}.
+ *
+ * @author Markus KARG (markus@headcrashing.eu)
+ */
+public class DataLeadStreamTest {
+
+ @Test
+ public void shouldDetectEolOnWrite() throws Exception {
+ // given
+ final var outputStream = new ByteArrayOutputStream();
+ final var dataLeadStream = new OutboundEventWriter.DataLeadStream(outputStream);
+
+ // when
+ dataLeadStream.write('A');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('B');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('C');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('D');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('E');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('F');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('\r');
+ dataLeadStream.write('\n');
+ dataLeadStream.write('G');
+ dataLeadStream.write("H".getBytes(UTF_8));
+ dataLeadStream.write("IJ".getBytes(UTF_8));
+ dataLeadStream.write("KLM".getBytes(UTF_8));
+ dataLeadStream.write("N\rO\nP\r\nQ\n\nR\r\rS\r\n\r\nT".getBytes(UTF_8));
+ dataLeadStream.write('\r');
+ dataLeadStream.write("U".getBytes(UTF_8));
+ dataLeadStream.write('\r');
+ dataLeadStream.write("\nV".getBytes(UTF_8));
+ dataLeadStream.write('\r');
+ dataLeadStream.write("\rW".getBytes(UTF_8));
+ dataLeadStream.write('\n');
+ dataLeadStream.write("X".getBytes(UTF_8));
+ dataLeadStream.write('\n');
+ dataLeadStream.write("\nY".getBytes(UTF_8));
+ dataLeadStream.write('\n');
+ dataLeadStream.write("\rZ".getBytes(UTF_8));
+ dataLeadStream.write("a\r".getBytes(UTF_8));
+ dataLeadStream.write('b');
+ dataLeadStream.write("c\n".getBytes(UTF_8));
+ dataLeadStream.write('d');
+ dataLeadStream.write("e\r".getBytes(UTF_8));
+ dataLeadStream.write('\r');
+ dataLeadStream.write("f\n".getBytes(UTF_8));
+ dataLeadStream.write('\n');
+ dataLeadStream.write("g\r".getBytes(UTF_8));
+ dataLeadStream.write('\n');
+ dataLeadStream.write("h\n".getBytes(UTF_8));
+ dataLeadStream.write('\r');
+ dataLeadStream.finish();
+
+ // then
+ assertEquals(
+ "data: A\ndata: B\ndata: C\ndata: D\ndata: \ndata: E\ndata: \ndata: F\ndata: \ndata: G"
+ + "H"
+ + "IJ"
+ + "KLM"
+ + "N\ndata: O\ndata: P\ndata: Q\ndata: \ndata: R\ndata: \ndata: S\ndata: \ndata: T"
+ + "\ndata: U"
+ + "\ndata: V"
+ + "\ndata: \ndata: W"
+ + "\ndata: X"
+ + "\ndata: \ndata: Y"
+ + "\ndata: \ndata: Z"
+ + "a\ndata: b"
+ + "c\ndata: d"
+ + "e\ndata: \ndata: "
+ + "f\ndata: \ndata: "
+ + "g\ndata: "
+ + "h\ndata: \ndata: ",
+ outputStream.toString(UTF_8));
+ }
+}