Nuxt: Reflected XSS in `navigateTo()` external redirect
Description
Summary
navigateTo() with external: true generates a server-side HTML redirect body containing a ` 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:
const encodedLoc = location.replace(/"/g, '%22')
nuxtApp.ssrContext!['~renderResponse'] = {
status: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: `<!DOCTYPE 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:
// 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>
Response body:
<!DOCTYPE html>">
The > after evil.example/x terminates the content="…" attribute, and the `` 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).
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A reflected XSS in Nuxt's `navigateTo` with `external: true` due to insufficient sanitization of HTML-significant characters in the meta-refresh redirect body.
Vulnerability
The vulnerability resides in Nuxt's server-side redirect logic in packages/nuxt/src/app/composables/router.ts. When navigateTo(url, { external: true }) is called, the framework generates an HTML response body containing a ` tag with the destination URL placed directly inside the content attribute. The URL is sanitized only by replacing " with %22, leaving <, >, &, and ' unencoded. This allows an attacker to break out of the attribute context by injecting a > character, enabling arbitrary HTML injection. The issue affects Nuxt versions prior to the 5.x branch fix. This is distinct from CVE-2024-34343, which addressed a javascript:` protocol bypass [1][3].
Exploitation
An attacker must control a URL that is passed to navigateTo(url, { external: true }). The most common scenario is an application that uses a query parameter (e.g., ?next=, ?redirect=) in middleware for post-login or return-to flows. The attacker crafts a request with a malicious URL containing > to close the attribute and inject arbitrary HTML/JavaScript. For example, the URL https://evil.example/x> would break out of the content="…" attribute. No authentication or advanced network position is required; the exploit is reflected as part of the HTTP response [1][3].
Impact
Successful exploitation results in reflected cross-site scripting (XSS). The injected script executes in the context of the application's origin during the server-rendered redirect response, before the meta-refresh fires. This allows the attacker to achieve full information disclosure, session hijacking, or arbitrary actions on behalf of the user. The impact is high as it compromises the confidentiality, integrity, and availability of the affected application [1][3].
Mitigation
The fix was implemented in pull request #35052 on the Nuxt GitHub repository [4]. Nuxt version 5.x (the patched branch) should be used to resolve the issue. Users should upgrade to a version that includes the commit from May 11, 2026, or later. For applications unable to upgrade immediately, the workaround is to avoid passing unsanitized user input to navigateTo(url, { external: true }), or to manually encode HTML-significant characters in the URL before passing it. No known CVE-2026-45669 is listed on the CISA KEV as of this writing.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
217b27b08e180fix(nuxt): encode html-significant characters in external redirect body (#35052)
3 files changed · +34 −2
packages/nuxt/src/app/composables/router.ts+13 −2 modified@@ -127,7 +127,18 @@ export interface NavigateToOptions { open?: OpenOptions } -const URL_QUOTE_RE = /"/g +const HTML_ATTR_UNSAFE_RE = /[&"'<>]/g +const HTML_ATTR_ENCODE_MAP: Record<string, string> = { + '&': '%26', + '"': '%22', + '\'': '%27', + '<': '%3C', + '>': '%3E', +} +function encodeForHtmlAttr (value: string): string { + return value.replace(HTML_ATTR_UNSAFE_RE, c => HTML_ATTR_ENCODE_MAP[c]!) +} + /** * A helper that aids in programmatic navigation within your Nuxt application. * @@ -201,7 +212,7 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na const redirect = async function (response: any) { // TODO: consider deprecating in favour of `app:rendered` and removing await nuxtApp.callHook('app:redirected') - const encodedLoc = location.replace(URL_QUOTE_RE, '%22') + const encodedLoc = encodeForHtmlAttr(location) const encodedHeader = encodeURL(location, isExternalHost) nuxtApp.ssrContext!['~renderResponse'] = {
test/basic.test.ts+14 −0 modified@@ -1217,6 +1217,20 @@ describe('navigate', () => { expect(status).toEqual(302) expect(headers.get('location') || '').toEqual(encodeURI('/cœur') + '?redirected=' + encodeURIComponent('https://google.com')) }) + + it('encodes HTML-significant characters in external redirect body', async () => { + const res = await fetch('/navigate-to-external-encode', { redirect: 'manual' }) + const body = await res.text() + expect(res.status).toEqual(302) + expect(res.headers.get('location')).not.toContain('<') + expect(res.headers.get('location')).not.toContain('>') + const content = body.match(/content="0; url=([^"]*)"/)?.[1] ?? '' + expect(content).not.toMatch(/[<>&"']/) + expect(content).toContain('%3C') + expect(content).toContain('%3E') + expect(content).toContain('%26') + expect(content).toContain('%27') + }) }) describe('preserves current instance', () => {
test/fixtures/basic/pages/navigate-to-external-encode.vue+7 −0 added@@ -0,0 +1,7 @@ +<template> + <div>You should not see me</div> +</template> + +<script setup lang="ts"> +await navigateTo('https://example.com/x><img src=x>&"\'', { external: true }) +</script>
1ca9c4494e7cfix(nuxt): encode html-significant characters in external redirect body (#35052)
3 files changed · +34 −2
packages/nuxt/src/app/composables/router.ts+13 −2 modified@@ -127,7 +127,18 @@ export interface NavigateToOptions { open?: OpenOptions } -const URL_QUOTE_RE = /"/g +const HTML_ATTR_UNSAFE_RE = /[&"'<>]/g +const HTML_ATTR_ENCODE_MAP: Record<string, string> = { + '&': '%26', + '"': '%22', + '\'': '%27', + '<': '%3C', + '>': '%3E', +} +function encodeForHtmlAttr (value: string): string { + return value.replace(HTML_ATTR_UNSAFE_RE, c => HTML_ATTR_ENCODE_MAP[c]!) +} + /** * A helper that aids in programmatic navigation within your Nuxt application. * @@ -201,7 +212,7 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na const redirect = async function (response: any) { // TODO: consider deprecating in favour of `app:rendered` and removing await nuxtApp.callHook('app:redirected') - const encodedLoc = location.replace(URL_QUOTE_RE, '%22') + const encodedLoc = encodeForHtmlAttr(location) const encodedHeader = encodeURL(location, isExternalHost) nuxtApp.ssrContext!['~renderResponse'] = {
test/basic.test.ts+14 −0 modified@@ -1223,6 +1223,20 @@ describe('navigate', () => { expect(status).toEqual(302) expect(headers.get('location') || '').toEqual(encodeURI('/cœur') + '?redirected=' + encodeURIComponent('https://google.com')) }) + + it('encodes HTML-significant characters in external redirect body', async () => { + const res = await fetch('/navigate-to-external-encode', { redirect: 'manual' }) + const body = await res.text() + expect(res.status).toEqual(302) + expect(res.headers.get('location')).not.toContain('<') + expect(res.headers.get('location')).not.toContain('>') + const content = body.match(/content="0; url=([^"]*)"/)?.[1] ?? '' + expect(content).not.toMatch(/[<>&"']/) + expect(content).toContain('%3C') + expect(content).toContain('%3E') + expect(content).toContain('%26') + expect(content).toContain('%27') + }) }) describe('preserves current instance', () => {
test/fixtures/basic/app/pages/navigate-to-external-encode.vue+7 −0 added@@ -0,0 +1,7 @@ +<template> + <div>You should not see me</div> +</template> + +<script setup lang="ts"> +await navigateTo('https://example.com/x><img src=x>&"\'', { external: true }) +</script>
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
3News mentions
0No linked articles in our index yet.