@@ -13,7 +13,7 @@ import type {
1313} from 'vue'
1414import { computed , defineComponent , h , inject , onBeforeUnmount , onMounted , provide , ref , resolveComponent , shallowRef , unref } from 'vue'
1515import type { RouteLocation , RouteLocationRaw , Router , RouterLink , RouterLinkProps , UseLinkReturn , useLink } from 'vue-router'
16- import { hasProtocol , joinURL , parseQuery , withTrailingSlash , withoutTrailingSlash } from 'ufo'
16+ import { hasProtocol , isScriptProtocol , joinURL , parseQuery , withTrailingSlash , withoutTrailingSlash } from 'ufo'
1717import { preloadRouteComponents } from '../composables/preload'
1818import { onNuxtReady } from '../composables/ready'
1919import { encodeRoutePath , navigateTo , resolveRouteObject , useRouter } from '../composables/router'
@@ -28,6 +28,28 @@ import { hashMode } from '#build/router.options.mjs'
2828
2929const firstNonUndefined = < T > ( ...args : ( T | undefined ) [ ] ) => args . find ( arg => arg !== undefined )
3030
31+ /**
32+ * Reject URL strings that would resolve to a script-capable protocol when used as the
33+ * `href` of an anchor element. Returns the value unchanged when safe, or `null`.
34+ *
35+ * The denylist is delegated to `ufo`'s `isScriptProtocol` so it stays in sync with the
36+ * check used by `navigateTo` (currently `javascript:`, `data:`, `vbscript:`, `blob:`).
37+ * ASCII whitespace and control characters are stripped first because browser URL
38+ * parsers tolerate them before the scheme, and `view-source:` is peeled recursively
39+ * because Chromium resolves it transparently to the inner URL.
40+ */
41+ function sanitizeExternalHref ( value : string ) : string | null {
42+ let candidate = value . replace ( / [ \u0000 - \u001f \s ] + / g, '' )
43+ while ( candidate . toLowerCase ( ) . startsWith ( 'view-source:' ) ) {
44+ candidate = candidate . slice ( 'view-source:' . length )
45+ }
46+ const colon = candidate . indexOf ( ':' )
47+ if ( colon > 0 && isScriptProtocol ( candidate . slice ( 0 , colon + 1 ) ) ) {
48+ return null
49+ }
50+ return value
51+ }
52+
3153const NuxtLinkDevKeySymbol : InjectionKey < boolean > = Symbol ( 'nuxt-link-dev-key' )
3254
3355/**
@@ -116,7 +138,7 @@ export interface NuxtLinkOptions extends
116138
117139type NuxtLinkDefaultSlotProps < CustomProp extends boolean = false > = CustomProp extends true
118140 ? {
119- href : string
141+ href : string | null
120142 navigate : ( e ?: MouseEvent ) => Promise < void >
121143 prefetch : ( nuxtApp ?: NuxtApp ) => Promise < void >
122144 route : ( RouteLocation & { href : string } ) | undefined
@@ -215,14 +237,16 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
215237 const href = computed ( ( ) => {
216238 const effectiveTrailingSlash = unref ( props . trailingSlash ) ?? options . trailingSlash
217239 if ( ! to . value || isAbsoluteUrl . value || isHashLinkWithoutHashMode ( to . value ) ) {
218- return to . value as string
240+ const raw = to . value as string
241+ return typeof raw === 'string' ? sanitizeExternalHref ( raw ) : raw
219242 }
220243
221244 if ( isExternal . value ) {
222245 const path = typeof to . value === 'object' && 'path' in to . value ? resolveRouteObject ( to . value ) : to . value
223246 // separately resolve route objects with a 'name' property and without 'path'
224247 const href = typeof path === 'object' ? router . resolve ( path ) . href : path
225- return applyTrailingSlashBehavior ( href , effectiveTrailingSlash )
248+ const safe = typeof href === 'string' ? sanitizeExternalHref ( href ) : href
249+ return safe === null ? null : applyTrailingSlashBehavior ( safe , effectiveTrailingSlash )
226250 }
227251
228252 if ( typeof to . value === 'object' ) {
@@ -243,10 +267,17 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
243267 isExactActive : link ?. isExactActive ?? computed ( ( ) => to . value === router . currentRoute . value . path ) ,
244268 route : link ?. route ?? computed ( ( ) => router . resolve ( to . value ) ) ,
245269 async navigate ( _e ?: MouseEvent ) {
270+ if ( href . value === null ) {
271+ if ( import . meta. dev ) {
272+ console . warn ( `[${ componentName } ] refused to navigate to a URL with a script-capable protocol.` )
273+ }
274+ return
275+ }
246276 await navigateTo ( href . value , { replace : unref ( props . replace ) , external : isExternal . value || hasTarget . value } )
247277 } ,
248- } satisfies ReturnType < typeof useLink > & {
278+ } satisfies Omit < ReturnType < typeof useLink > , 'href' > & {
249279 to : ComputedRef < RouteLocationRaw >
280+ href : ComputedRef < string | null >
250281 hasTarget : ComputedRef < boolean | null | undefined >
251282 isAbsoluteUrl : ComputedRef < boolean >
252283 isExternal : ComputedRef < boolean >
@@ -372,6 +403,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
372403
373404 if ( prefetched . value ) { return }
374405
406+ if ( href . value === null ) { return }
407+
375408 prefetched . value = true
376409
377410 const path = typeof to . value === 'string'
@@ -520,7 +553,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
520553 event . preventDefault ( )
521554
522555 try {
523- const encodedHref = encodeRoutePath ( href . value )
556+ const encodedHref = encodeRoutePath ( href . value ?? '' )
524557 return await ( props . replace ? router . replace ( encodedHref ) : router . push ( encodedHref ) )
525558 } finally {
526559 // Focus the target element for hash links to restore accessibility behavior
0 commit comments