diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts index 50b99476c5..093102a223 100644 --- a/packages/nuxt/src/app/composables/router.ts +++ b/packages/nuxt/src/app/composables/router.ts @@ -135,7 +135,8 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na return Promise.resolve() } - const isExternal = options?.external || hasProtocol(toPath, { acceptRelative: true }) + const isExternalHost = hasProtocol(toPath, { acceptRelative: true }) + const isExternal = options?.external || isExternalHost if (isExternal) { if (!options?.external) { throw new Error('Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.') @@ -166,10 +167,12 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na // TODO: consider deprecating in favour of `app:rendered` and removing await nuxtApp.callHook('app:redirected') const encodedLoc = location.replace(/"/g, '%22') + const encodedHeader = encodeURL(location, isExternalHost) + nuxtApp.ssrContext!._renderResponse = { statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302), body: ``, - headers: { location: encodeURI(location) }, + headers: { location: encodedHeader }, } return response } @@ -259,3 +262,17 @@ export const setPageLayout = (layout: unknown extends PageMeta['layout'] ? strin export function resolveRouteObject (to: Exclude) { return withQuery(to.path || '', to.query || {}) + (to.hash || '') } + +/** + * @internal + */ +export function encodeURL (location: string, isExternalHost = false) { + const url = new URL(location, 'http://localhost') + if (!isExternalHost) { + return url.pathname + url.search + url.hash + } + if (location.startsWith('//')) { + return url.toString().replace(url.protocol, '') + } + return url.toString() +} diff --git a/test/basic.test.ts b/test/basic.test.ts index b141ddc824..b28f7a893a 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1011,9 +1011,10 @@ describe('navigate', () => { }) it('expect to redirect with encoding', async () => { - const { status } = await fetch('/redirect-with-encode', { redirect: 'manual' }) + const { status, headers } = await fetch('/redirect-with-encode', { redirect: 'manual' }) expect(status).toEqual(302) + expect(headers.get('location') || '').toEqual(encodeURI('/cœur') + '?redirected=' + encodeURIComponent('https://google.com')) }) }) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 280fc23d68..4b8fa7990d 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -72,7 +72,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output-inline/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"531k"`) + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"532k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"76.2k"`) diff --git a/test/fixtures/basic/pages/redirect-with-encode.vue b/test/fixtures/basic/pages/redirect-with-encode.vue index a59a44d653..fc96279bc2 100644 --- a/test/fixtures/basic/pages/redirect-with-encode.vue +++ b/test/fixtures/basic/pages/redirect-with-encode.vue @@ -5,5 +5,5 @@ diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index e22488309b..c984567996 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -6,6 +6,7 @@ import { defineEventHandler } from 'h3' import { mount } from '@vue/test-utils' import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { hasProtocol } from 'ufo' import * as composables from '#app/composables' import { clearNuxtData, refreshNuxtData, useAsyncData, useNuxtData } from '#app/composables/asyncData' @@ -19,6 +20,7 @@ import { useId } from '#app/composables/id' import { callOnce } from '#app/composables/once' import { useLoadingIndicator } from '#app/composables/loading-indicator' import { useRouteAnnouncer } from '#app/composables/route-announcer' +import { encodeURL, resolveRouteObject } from '#app/composables/router' import { asyncDataDefaults, nuxtDefaultErrorValue } from '#build/nuxt.config.mjs' @@ -599,6 +601,29 @@ describe('routing utilities: `navigateTo`', () => { }) }) +describe('routing utilities: `resolveRouteObject`', () => { + it('resolveRouteObject should correctly resolve a route object', () => { + expect(resolveRouteObject({ path: '/test' })).toMatchInlineSnapshot(`"/test"`) + expect(resolveRouteObject({ path: '/test', hash: '#thing', query: { foo: 'bar' } })).toMatchInlineSnapshot(`"/test?foo=bar#thing"`) + }) +}) + +describe('routing utilities: `encodeURL`', () => { + const encode = (url: string) => { + const isExternal = hasProtocol(url, { acceptRelative: true }) + return encodeURL(url, isExternal) + } + it('encodeURL should correctly encode a URL', () => { + expect(encode('https://test.com')).toMatchInlineSnapshot(`"https://test.com/"`) + expect(encode('//test.com')).toMatchInlineSnapshot(`"//test.com/"`) + expect(encode('mailto:daniel@cœur.com')).toMatchInlineSnapshot(`"mailto:daniel@c%C5%93ur.com"`) + const encoded = encode('/cœur?redirected=' + encodeURIComponent('https://google.com')) + expect(new URL('/cœur', 'http://localhost').pathname).toMatchInlineSnapshot(`"/c%C5%93ur"`) + expect(encoded).toMatchInlineSnapshot(`"/c%C5%93ur?redirected=https%3A%2F%2Fgoogle.com"`) + expect(useRouter().resolve(encoded).query.redirected).toMatchInlineSnapshot(`"https://google.com"`) + }) +}) + describe('routing utilities: `useRoute`', () => { it('should show provide a mock route', () => { expect(useRoute()).toMatchObject({