{"id":"CVE-2026-33490","aliases":["GHSA-2j6q-whv2-gh6w"],"url":"https://o3.security/vulnerability/CVE-2026-33490","summary":"h3: Missing Path Segment Boundary Check in `mount()` Causes Middleware Execution on Unrelated Prefix-Matching Routes","details":"## Summary\n\nThe `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.\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 > 0) {\n      this[\"~middleware\"].push((event, next) => {\n        const originalPathname = event.url.pathname;\n        if (!originalPathname.startsWith(base)) {  // <-- 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\"], () => {\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's 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)) {  // <-- 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's `startsWith` check passes → admin middleware executes → sets `isAdmin = true`\n5. The middleware's \"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 — demonstrates context pollution across mount boundaries\nimport { H3 } from \"h3\";\n\nconst adminApp = new H3();\n\n// Admin middleware sets privileged context\nadminApp.use(() => {}, {\n  onRequest: (event) => {\n    event.context.isAdmin = true;\n  }\n});\n\nadminApp.get(\"/dashboard\", (event) => {\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) => {\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 && cd h3\ncorepack enable && pnpm install && pnpm build\n\n# 2. Save poc.js (above) and run\nbun poc.js\n# Output shows isAdmin: true — admin middleware leaked to /admin-public/info\n\n# 3. Verify the boundary leak with additional paths:\n# GET /administrator → admin middleware fires\n# GET /adminstuff   → admin middleware fires\n# GET /admin123     → admin middleware fires\n# GET /admi         → 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 > 0) {\n       this[\"~middleware\"].push((event, next) => {\n         const originalPathname = event.url.pathname;\n-        if (!originalPathname.startsWith(base)) {\n+        if (!originalPathname.startsWith(base) ||\n+            (originalPathname.length > base.length && 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 > _base.length && input[_base.length] !== \"/\")) {\n     return input;\n   }\n```\n\nThis ensures that `/admin` only matches `/admin`, `/admin/`, and `/admin/...` — never `/admin-public`, `/administrator`, or other coincidental string-prefix matches.","published":"2026-03-26T17:19:15.956Z","modified":"2026-08-12T03:51:40.433508181Z","cvss":{"score":3.7,"severity":"LOW","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"h3","fixedVersion":"2.0.1-rc.17"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/33xxx/CVE-2026-33490.json"},{"type":"ADVISORY","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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:40.433508181Z"}}