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({