GHSA-2J6Q-WHV2-GH6W
Vulnerability from github – Published: 2026-03-20 20:50 – Updated: 2026-03-27 20:58Summary
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
/adminwith middleware that setsevent.context.isAdmin = true - Developer defines a separate route
/admin-public/infoon the parent app that readsevent.context.isAdmin - Attacker requests
GET /admin-public/info - The
/adminmount'sstartsWithcheck passes → admin middleware executes → setsisAdmin = true - The middleware's "restore pathname" callback fires, control returns to the parent app
- The
/admin-public/infohandler seesevent.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/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. - 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.0.1-rc.16"
},
"package": {
"ecosystem": "npm",
"name": "h3"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.1-alpha.0"
},
{
"fixed": "2.0.1-rc.17"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-33490"
],
"database_specific": {
"cwe_ids": [
"CWE-706"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-20T20:50:27Z",
"nvd_published_at": "2026-03-26T18:16:30Z",
"severity": "LOW"
},
"details": "## Summary\n\nThe `mount()` method in h3 uses a simple `startsWith()` check to determine whether incoming requests fall under a mounted sub-application\u0027s 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.\n\n## Details\n\nThe root cause is in `src/h3.ts:127` within the `mount()` method:\n\n```typescript\n// src/h3.ts:122-135\nmount(base: string, input: FetchHandler | FetchableObject | H3Type) {\n if (\"handler\" in input) {\n if (input[\"~middleware\"].length \u003e 0) {\n this[\"~middleware\"].push((event, next) =\u003e {\n const originalPathname = event.url.pathname;\n if (!originalPathname.startsWith(base)) { // \u003c-- BUG: no segment boundary check\n return next();\n }\n event.url.pathname = event.url.pathname.slice(base.length) || \"/\";\n return callMiddleware(event, input[\"~middleware\"], () =\u003e {\n event.url.pathname = originalPathname;\n return next();\n });\n });\n }\n```\n\nWhen 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\u0027s entire middleware chain then executes for these unrelated paths.\n\nA secondary instance of the same flaw exists in `src/utils/internal/path.ts:40`:\n\n```typescript\n// src/utils/internal/path.ts:35-45\nexport function withoutBase(input: string = \"\", base: string = \"\"): string {\n if (!base || base === \"/\") {\n return input;\n }\n const _base = withoutTrailingSlash(base);\n if (!input.startsWith(_base)) { // \u003c-- Same flaw: no segment boundary check\n return input;\n }\n const trimmed = input.slice(_base.length);\n return trimmed[0] === \"/\" ? trimmed : \"/\" + trimmed;\n}\n```\n\nThe `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`).\n\n**Exploitation flow:**\n\n1. Developer mounts a sub-app at `/admin` with middleware that sets `event.context.isAdmin = true`\n2. Developer defines a separate route `/admin-public/info` on the parent app that reads `event.context.isAdmin`\n3. Attacker requests `GET /admin-public/info`\n4. The `/admin` mount\u0027s `startsWith` check passes \u2192 admin middleware executes \u2192 sets `isAdmin = true`\n5. The middleware\u0027s \"restore pathname\" callback fires, control returns to the parent app\n6. The `/admin-public/info` handler sees `event.context.isAdmin === true`\n\n## PoC\n\n```javascript\n// poc.js \u2014 demonstrates context pollution across mount boundaries\nimport { H3 } from \"h3\";\n\nconst adminApp = new H3();\n\n// Admin middleware sets privileged context\nadminApp.use(() =\u003e {}, {\n onRequest: (event) =\u003e {\n event.context.isAdmin = true;\n }\n});\n\nadminApp.get(\"/dashboard\", (event) =\u003e {\n return { admin: true, context: event.context };\n});\n\nconst app = new H3();\n\n// Mount admin sub-app at /admin\napp.mount(\"/admin\", adminApp);\n\n// Public route that happens to share the \"/admin\" prefix\napp.get(\"/admin-public/info\", (event) =\u003e {\n return {\n path: event.url.pathname,\n isAdmin: event.context.isAdmin ?? false, // Should always be false here\n };\n});\n\n// Test with fetch\nconst server = Bun.serve({ port: 3000, fetch: app.fetch });\n\n// This request should NOT trigger admin middleware, but it does\nconst res = await fetch(\"http://localhost:3000/admin-public/info\");\nconst body = await res.json();\nconsole.log(body);\n// Actual output: { path: \"/admin-public/info\", isAdmin: true }\n// Expected output: { path: \"/admin-public/info\", isAdmin: false }\n\nserver.stop();\n```\n\n**Steps to reproduce:**\n\n```bash\n# 1. Clone h3 and install\ngit clone https://github.com/h3js/h3 \u0026\u0026 cd h3\ncorepack enable \u0026\u0026 pnpm install \u0026\u0026 pnpm build\n\n# 2. Save poc.js (above) and run\nbun poc.js\n# Output shows isAdmin: true \u2014 admin middleware leaked to /admin-public/info\n\n# 3. Verify the boundary leak with additional paths:\n# GET /administrator \u2192 admin middleware fires\n# GET /adminstuff \u2192 admin middleware fires\n# GET /admin123 \u2192 admin middleware fires\n# GET /admi \u2192 admin middleware does NOT fire (correct)\n```\n\n## Impact\n\n- **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.\n- **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.\n- **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.\n- **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.\n\n## Recommended Fix\n\nAdd 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:\n\n**Fix for `src/h3.ts:127`:**\n\n```diff\n mount(base: string, input: FetchHandler | FetchableObject | H3Type) {\n if (\"handler\" in input) {\n if (input[\"~middleware\"].length \u003e 0) {\n this[\"~middleware\"].push((event, next) =\u003e {\n const originalPathname = event.url.pathname;\n- if (!originalPathname.startsWith(base)) {\n+ if (!originalPathname.startsWith(base) ||\n+ (originalPathname.length \u003e base.length \u0026\u0026 originalPathname[base.length] !== \"/\")) {\n return next();\n }\n```\n\n**Fix for `src/utils/internal/path.ts:40`:**\n\n```diff\n export function withoutBase(input: string = \"\", base: string = \"\"): string {\n if (!base || base === \"/\") {\n return input;\n }\n const _base = withoutTrailingSlash(base);\n- if (!input.startsWith(_base)) {\n+ if (!input.startsWith(_base) ||\n+ (input.length \u003e _base.length \u0026\u0026 input[_base.length] !== \"/\")) {\n return input;\n }\n```\n\nThis ensures that `/admin` only matches `/admin`, `/admin/`, and `/admin/...` \u2014 never `/admin-public`, `/administrator`, or other coincidental string-prefix matches.",
"id": "GHSA-2j6q-whv2-gh6w",
"modified": "2026-03-27T20:58:29Z",
"published": "2026-03-20T20:50:27Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/h3js/h3/security/advisories/GHSA-2j6q-whv2-gh6w"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33490"
},
{
"type": "PACKAGE",
"url": "https://github.com/h3js/h3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "h3: Missing Path Segment Boundary Check in `mount()` Causes Middleware Execution on Unrelated Prefix-Matching Routes"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.