fix(nuxt): use URL to encode redirected URLs (#27822)

This commit is contained in:
Daniel Roe 2024-06-26 11:58:45 +02:00
parent 0d854d9a06
commit 8f1376093d
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
5 changed files with 48 additions and 5 deletions

View File

@ -135,7 +135,8 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na
return Promise.resolve() 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 (isExternal) {
if (!options?.external) { if (!options?.external) {
throw new Error('Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.') 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 // TODO: consider deprecating in favour of `app:rendered` and removing
await nuxtApp.callHook('app:redirected') await nuxtApp.callHook('app:redirected')
const encodedLoc = location.replace(/"/g, '%22') const encodedLoc = location.replace(/"/g, '%22')
const encodedHeader = encodeURL(location, isExternalHost)
nuxtApp.ssrContext!._renderResponse = { nuxtApp.ssrContext!._renderResponse = {
statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302), statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`, body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
headers: { location: encodeURI(location) }, headers: { location: encodedHeader },
} }
return response return response
} }
@ -259,3 +262,17 @@ export const setPageLayout = (layout: unknown extends PageMeta['layout'] ? strin
export function resolveRouteObject (to: Exclude<RouteLocationRaw, string>) { export function resolveRouteObject (to: Exclude<RouteLocationRaw, string>) {
return withQuery(to.path || '', to.query || {}) + (to.hash || '') 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()
}

View File

@ -1011,9 +1011,10 @@ describe('navigate', () => {
}) })
it('expect to redirect with encoding', async () => { 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(status).toEqual(302)
expect(headers.get('location') || '').toEqual(encodeURI('/cœur') + '?redirected=' + encodeURIComponent('https://google.com'))
}) })
}) })

View File

@ -72,7 +72,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server') const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 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) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"76.2k"`) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"76.2k"`)

View File

@ -5,5 +5,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
await navigateTo('/cœur') await navigateTo('/cœur?redirected=' + encodeURIComponent('https://google.com'))
</script> </script>

View File

@ -6,6 +6,7 @@ import { defineEventHandler } from 'h3'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { hasProtocol } from 'ufo'
import * as composables from '#app/composables' import * as composables from '#app/composables'
import { clearNuxtData, refreshNuxtData, useAsyncData, useNuxtData } from '#app/composables/asyncData' 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 { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator' import { useLoadingIndicator } from '#app/composables/loading-indicator'
import { useRouteAnnouncer } from '#app/composables/route-announcer' import { useRouteAnnouncer } from '#app/composables/route-announcer'
import { encodeURL, resolveRouteObject } from '#app/composables/router'
import { asyncDataDefaults, nuxtDefaultErrorValue } from '#build/nuxt.config.mjs' 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`', () => { describe('routing utilities: `useRoute`', () => {
it('should show provide a mock route', () => { it('should show provide a mock route', () => {
expect(useRoute()).toMatchObject({ expect(useRoute()).toMatchObject({