VYPR
Medium severity5.3GHSA Advisory· Published May 19, 2026· Updated May 19, 2026

Nuxt: Reflected XSS in `navigateTo()` external redirect

CVE-2026-45669

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

2
  • Nuxt/NuxtGHSA2 versions
    >= 4.0.0-alpha.1, <= 4.4.5+ 1 more
    • (no CPE)range: >= 4.0.0-alpha.1, <= 4.4.5
    • (no CPE)range: <3.21.6, <4.4.6

Patches

2
17b27b08e180

fix(nuxt): encode html-significant characters in external redirect body (#35052)

https://github.com/nuxt/nuxtDaniel RoeMay 11, 2026Fixed in 3.21.6via llm-release-walk
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>
    
1ca9c4494e7c

fix(nuxt): encode html-significant characters in external redirect body (#35052)

https://github.com/nuxt/nuxtDaniel RoeMay 11, 2026Fixed in 4.4.6via llm-release-walk
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

3

News mentions

0

No linked articles in our index yet.