From 636b6128c8fc7709e3d0ac57af859ab3d808213f Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Tue, 1 Sep 2026 11:51:40 +0200 Subject: [PATCH 1/4] Remove line terminators in eventType/id/data --- .../sttp/model/sse/ServerSentEvent.scala | 13 ++++++-- .../sttp/model/sse/ServerSentEventTest.scala | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 70326cd0..93869d48 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -9,15 +9,22 @@ case class ServerSentEvent( retry: Option[Int] = None ) { override def toString: String = { - val _data = data.map(_.split("\n")).map(_.map(line => Some(s"data: $line"))).getOrElse(Array.empty[Option[String]]) - val _event = eventType.map(event => s"event: $event") - val _id = id.map(id => s"id: $id") + val _data = data + .map(_.split(ServerSentEvent.LineTerminators)) + .map(_.map(line => Some(s"data: $line"))) + .getOrElse(Array.empty[Option[String]]) + val _event = eventType.map(event => s"event: ${ServerSentEvent.removeLineTerminators(event)}") + val _id = id.map(id => s"id: ${ServerSentEvent.removeLineTerminators(id)}") val _retry = retry.map(retryCount => s"retry: $retryCount") (_data :+ _event :+ _id :+ _retry).flatten.mkString("\n") } } object ServerSentEvent { + private val LineTerminators = "\r\n|\r|\n" + + private def removeLineTerminators(s: String): String = s.replaceAll(LineTerminators, "") + // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = { event.foldLeft(ServerSentEvent()) { (event, line) => diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 5071c641..32608f4d 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -64,4 +64,36 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { |data: some data info 2 |data: some data info 3""".stripMargin } + + "composeSSE" should "split data on all line terminators" in { + val sse = ServerSentEvent(Some("line 1\r\nline 2\rline 3\nline 4")) + + sse.toString shouldBe + s"""data: line 1 + |data: line 2 + |data: line 3 + |data: line 4""".stripMargin + } + + "composeSSE" should "remove line terminators from the event type" in { + val sse = ServerSentEvent(eventType = Some("a\ndata: injected\rb\r\nc")) + sse.toString shouldBe "event: adata: injectedbc" + } + + "composeSSE" should "remove line terminators from the id" in { + val sse = ServerSentEvent(id = Some("a\ndata: injected\rb\r\nc")) + sse.toString shouldBe "id: adata: injectedbc" + } + + "composeSSE" should "not allow injecting fields through data, the event type or the id" in { + val malicious = "x\r\nevent: injected\rid: injected\ndata: injected" + val sse = ServerSentEvent(Some(malicious), Some(malicious), Some(malicious), Some(10)) + + ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe ServerSentEvent( + Some("x\nevent: injected\nid: injected\ndata: injected"), + Some("xevent: injectedid: injecteddata: injected"), + Some("xevent: injectedid: injecteddata: injected"), + Some(10) + ) + } } From 121b5b8ae7339e80bb4e50cf907a03c6ab1d2b03 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Wed, 2 Sep 2026 11:29:42 +0200 Subject: [PATCH 2/4] Preserve trailing line terminators --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 93869d48..846c7a60 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -10,7 +10,7 @@ case class ServerSentEvent( ) { override def toString: String = { val _data = data - .map(_.split(ServerSentEvent.LineTerminators)) + .map(_.split(ServerSentEvent.LineTerminators, -1)) .map(_.map(line => Some(s"data: $line"))) .getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: ${ServerSentEvent.removeLineTerminators(event)}") From 230b96d80acfec43dca9072ff7a6a12a1e986858 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Wed, 2 Sep 2026 11:57:39 +0200 Subject: [PATCH 3/4] Test for preserving trailing line terminators --- .../scala/sttp/model/sse/ServerSentEventTest.scala | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala index 32608f4d..dc36118a 100644 --- a/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala +++ b/core/src/test/scala/sttp/model/sse/ServerSentEventTest.scala @@ -96,4 +96,16 @@ class ServerSentEventTest extends AnyFlatSpec with Matchers { Some(10) ) } + + "composeSSE" should "keep a trailing line terminator in data" in { + ServerSentEvent(Some("a\n")).toString shouldBe "data: a\ndata: " + ServerSentEvent(Some("a\r")).toString shouldBe "data: a\ndata: " + ServerSentEvent(Some("a\r\n")).toString shouldBe "data: a\ndata: " + ServerSentEvent(Some("\n")).toString shouldBe "data: \ndata: " + } + + "composeSSE" should "round-trip data with a trailing line terminator" in { + val sse = ServerSentEvent(Some("a\n")) + ServerSentEvent.parse(sse.toString.split("\n").toList) shouldBe sse + } } From ab31bfe701c88f485b39b3b768b7c1037b2a7da3 Mon Sep 17 00:00:00 2001 From: Magda Stozek Date: Wed, 2 Sep 2026 12:55:22 +0200 Subject: [PATCH 4/4] Avoid regex for optimisation --- core/src/main/scala/sttp/model/sse/ServerSentEvent.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala index 846c7a60..3919a58a 100644 --- a/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala +++ b/core/src/main/scala/sttp/model/sse/ServerSentEvent.scala @@ -10,7 +10,7 @@ case class ServerSentEvent( ) { override def toString: String = { val _data = data - .map(_.split(ServerSentEvent.LineTerminators, -1)) + .map(ServerSentEvent.splitOnLineTerminators) .map(_.map(line => Some(s"data: $line"))) .getOrElse(Array.empty[Option[String]]) val _event = eventType.map(event => s"event: ${ServerSentEvent.removeLineTerminators(event)}") @@ -23,7 +23,12 @@ case class ServerSentEvent( object ServerSentEvent { private val LineTerminators = "\r\n|\r|\n" - private def removeLineTerminators(s: String): String = s.replaceAll(LineTerminators, "") + // performance: split("\n") skips the regex engine; with no CR, LF is the only terminator, so it's equivalent + private def splitOnLineTerminators(s: String): Array[String] = + if (s.indexOf('\r') < 0) s.split("\n", -1) else s.split(LineTerminators, -1) + + private def removeLineTerminators(s: String): String = + if (s.indexOf('\r') < 0 && s.indexOf('\n') < 0) s else s.replaceAll(LineTerminators, "") // https://html.spec.whatwg.org/multipage/server-sent-events.html def parse(event: List[String]): ServerSentEvent = {