GitHub Security Advisory: GHSA-4hxc-9384-m385
Package: h3 (NPM)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
Vulnerable versions: >= 2.0.0-beta.0, <= 2.0.1-rc.16
Patched in: 2.0.1-rc.17

Summary: h3: SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)

Details:
## 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