VYPR
Medium severityNVD Advisory· Published Jun 12, 2026· Updated Jun 12, 2026

CVE-2026-53722

CVE-2026-53722

Description

Nuxt component lacks URL scheme validation, enabling reflected DOM XSS via javascript: or vbscript: URLs when attacker input is bound to :to or :href props.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nuxt component lacks URL scheme validation, enabling reflected DOM XSS via `javascript:` or `vbscript:` URLs when attacker input is bound to `:to` or `:href` props.

Vulnerability

The ` component in Nuxt (prior to versions 3.21.7 and 4.4.7) does not validate the URL scheme of values bound to its to or href props before rendering them into the href attribute of the underlying element. An attacker who can control input (e.g., a query parameter, CMS field, or user-supplied profile URL) that is bound to or :href can supply a javascript: or vbscript: URL that is reflected verbatim in the markup [1][2][3]. The same issue affects custom slots that expose href and route.href props [3]. The sanitizeExternalHref function introduced in the fix rejects script-capable protocols (javascript:, data:, vbscript:, blob:) and strips leading whitespace, control characters, and view-source:` wrappers [1][2].

Exploitation

An attacker with the ability to inject a URL into an application's state (e.g., via a query parameter ?url=javascript:alert(1) in a “share this” handler, or a CMS field used for user profiles) can supply a javascript:, vbscript:, or data:text/html,... payload. The ` component renders the unsanitized value directly into the . When a user clicks the link, the browser executes the supplied script in the origin of the Nuxt application, achieving reflected DOM-based cross‑site scripting. A data:` payload does not execute in the application's origin but enables same‑tab phishing [3]. No authentication or special privileges are required if the application binds attacker‑controlled input to the component.

Impact

Successful exploitation results in reflected DOM‑based cross‑site scripting, allowing the attacker to execute arbitrary JavaScript in the context of the victim's session. This can lead to session theft, credential harvesting, or data exfiltration. A data: URL can be used for a same‑tab phishing attack that appears to originate from a legitimate application link. The vulnerability is rated Medium severity (CVSS 6.1) and affects all Nuxt applications that bind user‑controlled values to ` or :href` [3].

Mitigation

The vulnerability has been patched in Nuxt versions 3.21.7 and 4.4.7 [1][2][3]. Users should upgrade to these versions or later. There is no known workaround; applications must update the nuxt dependency. The fix introduces sanitizeExternalHref() which rejects script‑capable protocols and strips obfuscation attempts such as leading whitespace, control characters, and view-source: prefixes [1][2]. The issue is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog as of the publication date.

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Nuxt/Nuxtreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <3.21.7, <4.4.7

Patches

2
53284043dc21

fix(nuxt): reject script-capable protocols in `<NuxtLink>` href

https://github.com/nuxt/nuxtDaniel RoeJun 1, 2026via nvd-ref
2 files changed · +109 6
  • packages/nuxt/src/app/components/nuxt-link.ts+40 6 modified
    @@ -13,7 +13,7 @@ import type {
     } from 'vue'
     import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent, shallowRef, unref } from 'vue'
     import type { RouteLocation, RouteLocationRaw, Router, RouterLink, RouterLinkProps, UseLinkReturn, useLink } from 'vue-router'
    -import { hasProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo'
    +import { hasProtocol, isScriptProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo'
     import { preloadRouteComponents } from '../composables/preload'
     import { onNuxtReady } from '../composables/ready'
     import { encodeRoutePath, navigateTo, resolveRouteObject, useRouter } from '../composables/router'
    @@ -28,6 +28,29 @@ import { hashMode } from '#build/router.options.mjs'
     
     const firstNonUndefined = <T> (...args: (T | undefined)[]) => args.find(arg => arg !== undefined)
     
    +/**
    + * Reject URL strings that would resolve to a script-capable protocol when used as the
    + * `href` of an anchor element. Returns the value unchanged when safe, or `null`.
    + *
    + * The denylist is delegated to `ufo`'s `isScriptProtocol` so it stays in sync with the
    + * check used by `navigateTo` (currently `javascript:`, `data:`, `vbscript:`, `blob:`).
    + * ASCII whitespace and control characters are stripped first because browser URL
    + * parsers tolerate them before the scheme, and `view-source:` is peeled recursively
    + * because Chromium resolves it transparently to the inner URL.
    + */
    +function sanitizeExternalHref (value: string): string | null {
    +  // eslint-disable-next-line no-control-regex, unicorn/escape-case -- intentional: strip leading control chars before scheme check
    +  let candidate = value.replace(/[\u0000-\u001f\s]+/g, '')
    +  while (candidate.toLowerCase().startsWith('view-source:')) {
    +    candidate = candidate.slice('view-source:'.length)
    +  }
    +  const colon = candidate.indexOf(':')
    +  if (colon > 0 && isScriptProtocol(candidate.slice(0, colon + 1))) {
    +    return null
    +  }
    +  return value
    +}
    +
     const NuxtLinkDevKeySymbol: InjectionKey<boolean> = Symbol('nuxt-link-dev-key')
     
     /**
    @@ -116,7 +139,7 @@ export interface NuxtLinkOptions extends
     
     type NuxtLinkDefaultSlotProps<CustomProp extends boolean = false> = CustomProp extends true
       ? {
    -      href: string
    +      href: string | null
           navigate: (e?: MouseEvent) => Promise<void>
           prefetch: (nuxtApp?: NuxtApp) => Promise<void>
           route: (RouteLocation & { href: string }) | undefined
    @@ -215,14 +238,16 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
         const href = computed(() => {
           const effectiveTrailingSlash = unref(props.trailingSlash) ?? options.trailingSlash
           if (!to.value || isAbsoluteUrl.value || isHashLinkWithoutHashMode(to.value)) {
    -        return to.value as string
    +        const raw = to.value as string
    +        return typeof raw === 'string' ? sanitizeExternalHref(raw) : raw
           }
     
           if (isExternal.value) {
             const path = typeof to.value === 'object' && 'path' in to.value ? resolveRouteObject(to.value) : to.value
             // separately resolve route objects with a 'name' property and without 'path'
             const href = typeof path === 'object' ? router.resolve(path).href : path
    -        return applyTrailingSlashBehavior(href, effectiveTrailingSlash)
    +        const safe = typeof href === 'string' ? sanitizeExternalHref(href) : href
    +        return safe === null ? null : applyTrailingSlashBehavior(safe, effectiveTrailingSlash)
           }
     
           if (typeof to.value === 'object') {
    @@ -243,10 +268,17 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
           isExactActive: link?.isExactActive ?? computed(() => to.value === router.currentRoute.value.path),
           route: link?.route ?? computed(() => router.resolve(to.value)),
           async navigate (_e?: MouseEvent) {
    +        if (href.value === null) {
    +          if (import.meta.dev) {
    +            console.warn(`[${componentName}] refused to navigate to a URL with a script-capable protocol.`)
    +          }
    +          return
    +        }
             await navigateTo(href.value, { replace: unref(props.replace), external: isExternal.value || hasTarget.value })
           },
    -    } satisfies ReturnType<typeof useLink> & {
    +    } satisfies Omit<ReturnType<typeof useLink>, 'href'> & {
           to: ComputedRef<RouteLocationRaw>
    +      href: ComputedRef<string | null>
           hasTarget: ComputedRef<boolean | null | undefined>
           isAbsoluteUrl: ComputedRef<boolean>
           isExternal: ComputedRef<boolean>
    @@ -372,6 +404,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
     
             if (prefetched.value) { return }
     
    +        if (href.value === null) { return }
    +
             prefetched.value = true
     
             const path = typeof to.value === 'string'
    @@ -520,7 +554,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
                 event.preventDefault()
     
                 try {
    -              const encodedHref = encodeRoutePath(href.value)
    +              const encodedHref = encodeRoutePath(href.value ?? '')
                   return await (props.replace ? router.replace(encodedHref) : router.push(encodedHref))
                 } finally {
                   // Focus the target element for hash links to restore accessibility behavior
    
  • packages/nuxt/test/nuxt-link.test.ts+69 0 modified
    @@ -150,6 +150,67 @@ describe('nuxt-link:propsOrAttributes', () => {
             expect(nuxtLink({ to: { path: '/to' }, external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
             expect(nuxtLink({ to: '/to', external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
           })
    +
    +      it('strips script-capable protocols from auto-detected external links', () => {
    +        const cases = [
    +          'javascript:alert(1)',
    +          ' javascript:alert(1)',
    +          'JAVASCRIPT:alert(1)',
    +          'java\tscript:alert(1)',
    +          'data:text/html,<script>alert(1)</script>',
    +          'vbscript:msgbox(1)',
    +          'view-source:javascript:alert(1)',
    +          'view-source:view-source:javascript:alert(1)',
    +          'blob:https://example.test/abc',
    +        ]
    +        for (const to of cases) {
    +          expect(nuxtLink({ to }).props.href, to).toBe(null)
    +        }
    +      })
    +
    +      it('strips script-capable protocols when the caller forces `external: true`', () => {
    +        const cases = [
    +          'javascript:alert(1)',
    +          '\u0001javascript:alert(1)',
    +          '\tjavascript:alert(1)',
    +          'data:text/html,<script>alert(1)</script>',
    +          'view-source:javascript:alert(1)',
    +        ]
    +        for (const to of cases) {
    +          expect(nuxtLink({ to, external: true }).props.href, to).toBe(null)
    +        }
    +      })
    +
    +      it('preserves safe external href values', () => {
    +        const safe = [
    +          'https://nuxtjs.org',
    +          'http://nuxtjs.org',
    +          '//nuxtjs.org',
    +          'mailto:hello@nuxtjs.org',
    +          'tel:0123456789',
    +          'ftp://example.test/file',
    +        ]
    +        for (const to of safe) {
    +          expect(nuxtLink({ to }).props.href, to).toBe(to)
    +        }
    +      })
    +
    +      it('strips script-capable protocols passed through the `custom` slot', () => {
    +        let received: { href: string | null } | undefined
    +        const component = defineNuxtLink({ componentName: 'NuxtLink' })
    +        ;(component as any).setup(
    +          { to: 'javascript:alert(1)', custom: true },
    +          {
    +            slots: {
    +              default: (slotProps: { href: string | null }) => {
    +                received = slotProps
    +                return null
    +              },
    +            },
    +          },
    +        )()
    +        expect(received?.href).toBe(null)
    +      })
         })
     
         describe('target', () => {
    @@ -463,6 +524,14 @@ describe('nuxt-link:useLink', () => {
         expect(navigateToMock).toHaveBeenCalledWith('/about', { replace: true, external: false })
       })
     
    +  it('navigate() skips links with a script-capable protocol', async () => {
    +    navigateToMock.mockClear()
    +    const component = defineNuxtLink({ componentName: 'NuxtLink' })
    +    const link = component.useLink({ to: 'javascript:alert(1)' })
    +    await link.navigate()
    +    expect(navigateToMock).not.toHaveBeenCalled()
    +  })
    +
       it('applies trailingSlash with Ref `to`', () => {
         const component = defineNuxtLink({ componentName: 'NuxtLink', trailingSlash: 'append' })
         const to = ref('/about')
    
0103ce06fbbb

fix(nuxt): reject script-capable protocols in `<NuxtLink>` href

https://github.com/nuxt/nuxtDaniel RoeJun 1, 2026via nvd-ref
2 files changed · +108 6
  • packages/nuxt/src/app/components/nuxt-link.ts+39 6 modified
    @@ -13,7 +13,7 @@ import type {
     } from 'vue'
     import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent, shallowRef, unref } from 'vue'
     import type { RouteLocation, RouteLocationRaw, Router, RouterLink, RouterLinkProps, UseLinkReturn, useLink } from 'vue-router'
    -import { hasProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo'
    +import { hasProtocol, isScriptProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo'
     import { preloadRouteComponents } from '../composables/preload'
     import { onNuxtReady } from '../composables/ready'
     import { encodeRoutePath, navigateTo, resolveRouteObject, useRouter } from '../composables/router'
    @@ -28,6 +28,28 @@ import { hashMode } from '#build/router.options.mjs'
     
     const firstNonUndefined = <T> (...args: (T | undefined)[]) => args.find(arg => arg !== undefined)
     
    +/**
    + * Reject URL strings that would resolve to a script-capable protocol when used as the
    + * `href` of an anchor element. Returns the value unchanged when safe, or `null`.
    + *
    + * The denylist is delegated to `ufo`'s `isScriptProtocol` so it stays in sync with the
    + * check used by `navigateTo` (currently `javascript:`, `data:`, `vbscript:`, `blob:`).
    + * ASCII whitespace and control characters are stripped first because browser URL
    + * parsers tolerate them before the scheme, and `view-source:` is peeled recursively
    + * because Chromium resolves it transparently to the inner URL.
    + */
    +function sanitizeExternalHref (value: string): string | null {
    +  let candidate = value.replace(/[\u0000-\u001f\s]+/g, '')
    +  while (candidate.toLowerCase().startsWith('view-source:')) {
    +    candidate = candidate.slice('view-source:'.length)
    +  }
    +  const colon = candidate.indexOf(':')
    +  if (colon > 0 && isScriptProtocol(candidate.slice(0, colon + 1))) {
    +    return null
    +  }
    +  return value
    +}
    +
     const NuxtLinkDevKeySymbol: InjectionKey<boolean> = Symbol('nuxt-link-dev-key')
     
     /**
    @@ -116,7 +138,7 @@ export interface NuxtLinkOptions extends
     
     type NuxtLinkDefaultSlotProps<CustomProp extends boolean = false> = CustomProp extends true
       ? {
    -      href: string
    +      href: string | null
           navigate: (e?: MouseEvent) => Promise<void>
           prefetch: (nuxtApp?: NuxtApp) => Promise<void>
           route: (RouteLocation & { href: string }) | undefined
    @@ -215,14 +237,16 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
         const href = computed(() => {
           const effectiveTrailingSlash = unref(props.trailingSlash) ?? options.trailingSlash
           if (!to.value || isAbsoluteUrl.value || isHashLinkWithoutHashMode(to.value)) {
    -        return to.value as string
    +        const raw = to.value as string
    +        return typeof raw === 'string' ? sanitizeExternalHref(raw) : raw
           }
     
           if (isExternal.value) {
             const path = typeof to.value === 'object' && 'path' in to.value ? resolveRouteObject(to.value) : to.value
             // separately resolve route objects with a 'name' property and without 'path'
             const href = typeof path === 'object' ? router.resolve(path).href : path
    -        return applyTrailingSlashBehavior(href, effectiveTrailingSlash)
    +        const safe = typeof href === 'string' ? sanitizeExternalHref(href) : href
    +        return safe === null ? null : applyTrailingSlashBehavior(safe, effectiveTrailingSlash)
           }
     
           if (typeof to.value === 'object') {
    @@ -243,10 +267,17 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
           isExactActive: link?.isExactActive ?? computed(() => to.value === router.currentRoute.value.path),
           route: link?.route ?? computed(() => router.resolve(to.value)),
           async navigate (_e?: MouseEvent) {
    +        if (href.value === null) {
    +          if (import.meta.dev) {
    +            console.warn(`[${componentName}] refused to navigate to a URL with a script-capable protocol.`)
    +          }
    +          return
    +        }
             await navigateTo(href.value, { replace: unref(props.replace), external: isExternal.value || hasTarget.value })
           },
    -    } satisfies ReturnType<typeof useLink> & {
    +    } satisfies Omit<ReturnType<typeof useLink>, 'href'> & {
           to: ComputedRef<RouteLocationRaw>
    +      href: ComputedRef<string | null>
           hasTarget: ComputedRef<boolean | null | undefined>
           isAbsoluteUrl: ComputedRef<boolean>
           isExternal: ComputedRef<boolean>
    @@ -372,6 +403,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
     
             if (prefetched.value) { return }
     
    +        if (href.value === null) { return }
    +
             prefetched.value = true
     
             const path = typeof to.value === 'string'
    @@ -520,7 +553,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
                 event.preventDefault()
     
                 try {
    -              const encodedHref = encodeRoutePath(href.value)
    +              const encodedHref = encodeRoutePath(href.value ?? '')
                   return await (props.replace ? router.replace(encodedHref) : router.push(encodedHref))
                 } finally {
                   // Focus the target element for hash links to restore accessibility behavior
    
  • packages/nuxt/test/nuxt-link.test.ts+69 0 modified
    @@ -150,6 +150,67 @@ describe('nuxt-link:propsOrAttributes', () => {
             expect(nuxtLink({ to: { path: '/to' }, external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
             expect(nuxtLink({ to: '/to', external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
           })
    +
    +      it('strips script-capable protocols from auto-detected external links', () => {
    +        const cases = [
    +          'javascript:alert(1)',
    +          ' javascript:alert(1)',
    +          'JAVASCRIPT:alert(1)',
    +          'java\tscript:alert(1)',
    +          'data:text/html,<script>alert(1)</script>',
    +          'vbscript:msgbox(1)',
    +          'view-source:javascript:alert(1)',
    +          'view-source:view-source:javascript:alert(1)',
    +          'blob:https://example.test/abc',
    +        ]
    +        for (const to of cases) {
    +          expect(nuxtLink({ to }).props.href, to).toBe(null)
    +        }
    +      })
    +
    +      it('strips script-capable protocols when the caller forces `external: true`', () => {
    +        const cases = [
    +          'javascript:alert(1)',
    +          '\u0001javascript:alert(1)',
    +          '\tjavascript:alert(1)',
    +          'data:text/html,<script>alert(1)</script>',
    +          'view-source:javascript:alert(1)',
    +        ]
    +        for (const to of cases) {
    +          expect(nuxtLink({ to, external: true }).props.href, to).toBe(null)
    +        }
    +      })
    +
    +      it('preserves safe external href values', () => {
    +        const safe = [
    +          'https://nuxtjs.org',
    +          'http://nuxtjs.org',
    +          '//nuxtjs.org',
    +          'mailto:hello@nuxtjs.org',
    +          'tel:0123456789',
    +          'ftp://example.test/file',
    +        ]
    +        for (const to of safe) {
    +          expect(nuxtLink({ to }).props.href, to).toBe(to)
    +        }
    +      })
    +
    +      it('strips script-capable protocols passed through the `custom` slot', () => {
    +        let received: { href: string | null } | undefined
    +        const component = defineNuxtLink({ componentName: 'NuxtLink' })
    +        ;(component as any).setup(
    +          { to: 'javascript:alert(1)', custom: true },
    +          {
    +            slots: {
    +              default: (slotProps: { href: string | null }) => {
    +                received = slotProps
    +                return null
    +              },
    +            },
    +          },
    +        )()
    +        expect(received?.href).toBe(null)
    +      })
         })
     
         describe('target', () => {
    @@ -463,6 +524,14 @@ describe('nuxt-link:useLink', () => {
         expect(navigateToMock).toHaveBeenCalledWith('/about', { replace: true, external: false })
       })
     
    +  it('navigate() skips links with a script-capable protocol', async () => {
    +    navigateToMock.mockClear()
    +    const component = defineNuxtLink({ componentName: 'NuxtLink' })
    +    const link = component.useLink({ to: 'javascript:alert(1)' })
    +    await link.navigate()
    +    expect(navigateToMock).not.toHaveBeenCalled()
    +  })
    +
       it('applies trailingSlash with Ref `to`', () => {
         const component = defineNuxtLink({ componentName: 'NuxtLink', trailingSlash: 'append' })
         const to = ref('/about')
    

Vulnerability mechanics

Root cause

"Missing URL scheme validation in `<NuxtLink>` allows script-capable protocols to be rendered verbatim into the `href` attribute of the anchor element."

Attack vector

An attacker supplies a `javascript:`, `vbscript:`, or `data:` URL through attacker-controlled input (e.g., a query parameter, CMS field, or user-supplied profile URL) that is bound to `<NuxtLink :to>` or `:href`. The component renders the malicious URL verbatim into the `href` attribute of the rendered `<a>` element. Clicking the link executes the supplied script in the origin of the Nuxt application, resulting in reflected DOM-based cross-site scripting [ref_id=3].

Affected code

The vulnerability resides in `packages/nuxt/src/app/components/nuxt-link.ts` where `<NuxtLink>` renders user-supplied values bound to its `to` or `href` props directly into the `href` attribute of the underlying `<a>` element without validating the URL scheme. The same unsanitized value is also exposed to consumers of the component's custom slot via the `href` and `route.href` props [ref_id=3].

What the fix does

The patch introduces a `sanitizeExternalHref` function that strips leading ASCII whitespace and control characters, recursively unwraps `view-source:` prefixes, and then delegates to `ufo`'s `isScriptProtocol` to reject any remaining script-capable schemes (`javascript:`, `data:`, `vbscript:`, `blob:`). When a dangerous protocol is detected, the function returns `null` instead of the original value, which causes the rendered `href` to be empty and the `navigate()` method to skip navigation with a dev-mode warning [patch_id=5722704][patch_id=5722705].

Preconditions

  • inputThe application must bind attacker-controlled input to the `to` or `href` prop of ``.
  • inputThe attacker's payload must be a URL with a script-capable scheme (e.g., `javascript:`, `vbscript:`, `data:`).
  • inputThe victim must click the rendered link.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.