Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0103ce0

Browse files
committed
fix(nuxt): reject script-capable protocols in <NuxtLink> href
Refs: GHSA-934w-87qh-qr26
1 parent 07e39cd commit 0103ce0

2 files changed

Lines changed: 108 additions & 6 deletions

File tree

packages/nuxt/src/app/components/nuxt-link.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
} from 'vue'
1414
import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent, shallowRef, unref } from 'vue'
1515
import 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'
1717
import { preloadRouteComponents } from '../composables/preload'
1818
import { onNuxtReady } from '../composables/ready'
1919
import { encodeRoutePath, navigateTo, resolveRouteObject, useRouter } from '../composables/router'
@@ -28,6 +28,28 @@ import { hashMode } from '#build/router.options.mjs'
2828

2929
const 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+
3153
const NuxtLinkDevKeySymbol: InjectionKey<boolean> = Symbol('nuxt-link-dev-key')
3254

3355
/**
@@ -116,7 +138,7 @@ export interface NuxtLinkOptions extends
116138

117139
type 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

packages/nuxt/test/nuxt-link.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,67 @@ describe('nuxt-link:propsOrAttributes', () => {
150150
expect(nuxtLink({ to: { path: '/to' }, external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
151151
expect(nuxtLink({ to: '/to', external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/')
152152
})
153+
154+
it('strips script-capable protocols from auto-detected external links', () => {
155+
const cases = [
156+
'javascript:alert(1)',
157+
' javascript:alert(1)',
158+
'JAVASCRIPT:alert(1)',
159+
'java\tscript:alert(1)',
160+
'data:text/html,<script>alert(1)</script>',
161+
'vbscript:msgbox(1)',
162+
'view-source:javascript:alert(1)',
163+
'view-source:view-source:javascript:alert(1)',
164+
'blob:https://example.test/abc',
165+
]
166+
for (const to of cases) {
167+
expect(nuxtLink({ to }).props.href, to).toBe(null)
168+
}
169+
})
170+
171+
it('strips script-capable protocols when the caller forces `external: true`', () => {
172+
const cases = [
173+
'javascript:alert(1)',
174+
'\u0001javascript:alert(1)',
175+
'\tjavascript:alert(1)',
176+
'data:text/html,<script>alert(1)</script>',
177+
'view-source:javascript:alert(1)',
178+
]
179+
for (const to of cases) {
180+
expect(nuxtLink({ to, external: true }).props.href, to).toBe(null)
181+
}
182+
})
183+
184+
it('preserves safe external href values', () => {
185+
const safe = [
186+
'https://nuxtjs.org',
187+
'http://nuxtjs.org',
188+
'//nuxtjs.org',
189+
190+
'tel:0123456789',
191+
'ftp://example.test/file',
192+
]
193+
for (const to of safe) {
194+
expect(nuxtLink({ to }).props.href, to).toBe(to)
195+
}
196+
})
197+
198+
it('strips script-capable protocols passed through the `custom` slot', () => {
199+
let received: { href: string | null } | undefined
200+
const component = defineNuxtLink({ componentName: 'NuxtLink' })
201+
;(component as any).setup(
202+
{ to: 'javascript:alert(1)', custom: true },
203+
{
204+
slots: {
205+
default: (slotProps: { href: string | null }) => {
206+
received = slotProps
207+
return null
208+
},
209+
},
210+
},
211+
)()
212+
expect(received?.href).toBe(null)
213+
})
153214
})
154215

155216
describe('target', () => {
@@ -463,6 +524,14 @@ describe('nuxt-link:useLink', () => {
463524
expect(navigateToMock).toHaveBeenCalledWith('/about', { replace: true, external: false })
464525
})
465526

527+
it('navigate() skips links with a script-capable protocol', async () => {
528+
navigateToMock.mockClear()
529+
const component = defineNuxtLink({ componentName: 'NuxtLink' })
530+
const link = component.useLink({ to: 'javascript:alert(1)' })
531+
await link.navigate()
532+
expect(navigateToMock).not.toHaveBeenCalled()
533+
})
534+
466535
it('applies trailingSlash with Ref `to`', () => {
467536
const component = defineNuxtLink({ componentName: 'NuxtLink', trailingSlash: 'append' })
468537
const to = ref('/about')

0 commit comments

Comments
 (0)