@@ -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,29 @@ 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+ // eslint-disable-next-line no-control-regex, unicorn/escape-case -- intentional: strip leading control chars before scheme check
43+ let candidate = value . replace ( / [ \u0000 - \u001f \s ] + / g, '' )
44+ while ( candidate . toLowerCase ( ) . startsWith ( 'view-source:' ) ) {
45+ candidate = candidate . slice ( 'view-source:' . length )
46+ }
47+ const colon = candidate . indexOf ( ':' )
48+ if ( colon > 0 && isScriptProtocol ( candidate . slice ( 0 , colon + 1 ) ) ) {
49+ return null
50+ }
51+ return value
52+ }
53+
3154const NuxtLinkDevKeySymbol : InjectionKey < boolean > = Symbol ( 'nuxt-link-dev-key' )
3255
3356/**
@@ -116,7 +139,7 @@ export interface NuxtLinkOptions extends
116139
117140type NuxtLinkDefaultSlotProps < CustomProp extends boolean = false > = CustomProp extends true
118141 ? {
119- href : string
142+ href : string | null
120143 navigate : ( e ?: MouseEvent ) => Promise < void >
121144 prefetch : ( nuxtApp ?: NuxtApp ) => Promise < void >
122145 route : ( RouteLocation & { href : string } ) | undefined
@@ -215,14 +238,16 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
215238 const href = computed ( ( ) => {
216239 const effectiveTrailingSlash = unref ( props . trailingSlash ) ?? options . trailingSlash
217240 if ( ! to . value || isAbsoluteUrl . value || isHashLinkWithoutHashMode ( to . value ) ) {
218- return to . value as string
241+ const raw = to . value as string
242+ return typeof raw === 'string' ? sanitizeExternalHref ( raw ) : raw
219243 }
220244
221245 if ( isExternal . value ) {
222246 const path = typeof to . value === 'object' && 'path' in to . value ? resolveRouteObject ( to . value ) : to . value
223247 // separately resolve route objects with a 'name' property and without 'path'
224248 const href = typeof path === 'object' ? router . resolve ( path ) . href : path
225- return applyTrailingSlashBehavior ( href , effectiveTrailingSlash )
249+ const safe = typeof href === 'string' ? sanitizeExternalHref ( href ) : href
250+ return safe === null ? null : applyTrailingSlashBehavior ( safe , effectiveTrailingSlash )
226251 }
227252
228253 if ( typeof to . value === 'object' ) {
@@ -243,10 +268,17 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
243268 isExactActive : link ?. isExactActive ?? computed ( ( ) => to . value === router . currentRoute . value . path ) ,
244269 route : link ?. route ?? computed ( ( ) => router . resolve ( to . value ) ) ,
245270 async navigate ( _e ?: MouseEvent ) {
271+ if ( href . value === null ) {
272+ if ( import . meta. dev ) {
273+ console . warn ( `[${ componentName } ] refused to navigate to a URL with a script-capable protocol.` )
274+ }
275+ return
276+ }
246277 await navigateTo ( href . value , { replace : unref ( props . replace ) , external : isExternal . value || hasTarget . value } )
247278 } ,
248- } satisfies ReturnType < typeof useLink > & {
279+ } satisfies Omit < ReturnType < typeof useLink > , 'href' > & {
249280 to : ComputedRef < RouteLocationRaw >
281+ href : ComputedRef < string | null >
250282 hasTarget : ComputedRef < boolean | null | undefined >
251283 isAbsoluteUrl : ComputedRef < boolean >
252284 isExternal : ComputedRef < boolean >
@@ -372,6 +404,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
372404
373405 if ( prefetched . value ) { return }
374406
407+ if ( href . value === null ) { return }
408+
375409 prefetched . value = true
376410
377411 const path = typeof to . value === 'string'
@@ -520,7 +554,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
520554 event . preventDefault ( )
521555
522556 try {
523- const encodedHref = encodeRoutePath ( href . value )
557+ const encodedHref = encodeRoutePath ( href . value ?? '' )
524558 return await ( props . replace ? router . replace ( encodedHref ) : router . push ( encodedHref ) )
525559 } finally {
526560 // Focus the target element for hash links to restore accessibility behavior
0 commit comments