Skip to content

[Security] SSE event injection via unsanitized newlines in Emitter event/id fields #2579

@eddieran

Description

@eddieran

Summary

The SSE Emitter writes event and id parameters directly into the response stream without stripping newline characters. If application code passes user-controlled data as an event name or ID to SseClient.sendEvent(), an attacker can inject arbitrary SSE fields into the stream.

Details

In javalin/src/main/java/io/javalin/http/sse/Emitter.kt, the emit(event, data, id) method constructs SSE frames by string concatenation:

if (id != null) {
    write("id: $id$NEW_LINE")       // no newline sanitization
}
write("event: $event$NEW_LINE")      // no newline sanitization

The SSE specification (https://html.spec.whatwg.org/multipage/server-sent-events.html) defines events as newline-delimited fields. A newline inside the event or id value terminates that field prematurely, and any text after the newline is parsed as a new field. This allows injection of data:, event:, id:, and retry: lines.

The sendComment() path already handles this correctly:

fun emit(comment: String) =
    try {
        comment.split(NEW_LINE).forEach {
            write("$COMMENT_PREFIX $it$NEW_LINE")
        }

And the data field is also handled safely by reading line-by-line. Only event and id lack this protection.

Steps to Reproduce

Application code:

app.sse("/live") { client ->
    val eventType = ctx.queryParam("type") ?: "update"
    client.sendEvent(eventType, jsonData)
}

Request: GET /live?type=x%0Adata:%20%7B%22injected%22:true%7D%0A%0Aevent:%20update

This produces:

event: x
data: {"injected":true}

event: update
data: <legitimate data>

The browser's EventSource delivers {"injected":true} as a complete event, injecting a fake event into the stream.

Impact

  • Injection of arbitrary data payloads that client-side JavaScript processes as trusted server events
  • Manipulation of the Last-Event-ID reconnection header via injected id: field
  • Modification of the client reconnection interval via injected retry: field

Suggested Fix

Strip \r and \n from event and id before writing:

val sanitizedId = id?.replace(Regex("[\r\n]"), "")
val sanitizedEvent = event.replace(Regex("[\r\n]"), "")

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions