Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/bridge/src/runtime/composables/router.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { getCurrentInstance, reactive } from 'vue'
import type VueRouter from 'vue-router'
import type { Location, RawLocation, Route, NavigationFailure } from 'vue-router'
import { sendRedirect } from 'h3'
import { sanitizeStatusCode } from 'h3'
import { useRouter as useVueRouter, useRoute as useVueRoute } from 'vue-router/composables'
import { hasProtocol, joinURL, parseURL, withQuery } from 'ufo'
import { useNuxtApp, callWithNuxt, useRuntimeConfig } from '../nuxt'
import { createError, showError } from './error'
import type { NuxtError } from './error'

const URL_QUOTE_RE = /"/g

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()
}

// Auto-import equivalents for `vue-router`
export const useRouter = () => {
if (getCurrentInstance()) {
Expand Down Expand Up @@ -138,11 +151,20 @@ export const navigateTo = (to: RawLocation | undefined | null, options?: Navigat
const fullPath = typeof to === 'string' || isExternal ? toPath : router.resolve(to).resolved.fullPath || '/'
const location = isExternal ? toPath : joinURL(useRuntimeConfig().app.baseURL, fullPath)

const isExternalHost = hasProtocol(toPath, { acceptRelative: true })

const redirect = async function (response: any) {
// @ts-expect-error
await nuxtApp.callHook('app:redirected')

await sendRedirect(nuxtApp.ssrContext!.event, location, options?.redirectCode || 302)
const encodedLoc = location.replace(URL_QUOTE_RE, '%22')
const encodedHeader = encodeURL(location, isExternalHost)

nuxtApp.ssrContext!['~renderResponse'] = {
statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
headers: { location: encodedHeader }
}
Comment on lines +154 to +167
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape the meta‑refresh URL to prevent HTML injection.

Only quotes are escaped today, so a location containing &quot; (or </>) can break the attribute and inject markup. Please HTML‑escape the value used in the meta refresh body.

πŸ›‘οΈ Suggested fix
-        const encodedLoc = location.replace(URL_QUOTE_RE, '%22')
-        const encodedHeader = encodeURL(location, isExternalHost)
+        const encodedHeader = encodeURL(location, isExternalHost)
+        const encodedLoc = encodedHeader
+          .replace(/&/g, '&amp;')
+          .replace(/</g, '&lt;')
+          .replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;')
 
         nuxtApp.ssrContext!['~renderResponse'] = {
           statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302),
           body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isExternalHost = hasProtocol(toPath, { acceptRelative: true })
const redirect = async function (response: any) {
// @ts-expect-error
await nuxtApp.callHook('app:redirected')
await sendRedirect(nuxtApp.ssrContext!.event, location, options?.redirectCode || 302)
const encodedLoc = location.replace(URL_QUOTE_RE, '%22')
const encodedHeader = encodeURL(location, isExternalHost)
nuxtApp.ssrContext!['~renderResponse'] = {
statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
headers: { location: encodedHeader }
}
const isExternalHost = hasProtocol(toPath, { acceptRelative: true })
const redirect = async function (response: any) {
// `@ts-expect-error`
await nuxtApp.callHook('app:redirected')
const encodedHeader = encodeURL(location, isExternalHost)
const encodedLoc = encodedHeader
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
nuxtApp.ssrContext!['~renderResponse'] = {
statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302),
body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`,
headers: { location: encodedHeader }
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bridge/src/runtime/composables/router.ts` around lines 154 - 167,
The meta-refresh body uses encodedLoc which only replaces quotes and can allow
HTML injection (in function redirect); escape the location for HTML context
before interpolating into the meta tag: add or reuse an HTML-escape utility
(e.g., escapeHtml) and apply it to the location (or encodedLoc) when building
the body string assigned to nuxtApp.ssrContext!['~renderResponse'].body, while
keeping the existing encodeURL usage for the Location header (encodedHeader) and
preserving sanitizeStatusCode for the statusCode.

return response
}

Expand Down
9 changes: 7 additions & 2 deletions packages/bridge/src/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface NuxtSSRContext extends SSRContext {
url: string
noSSR: boolean
redirected?: boolean
'~renderResponse'?: RenderResponse
event: H3Event
/** @deprecated use `ssrContext.event` instead */
req: H3Event['req']
Expand Down Expand Up @@ -198,6 +199,12 @@ export default defineRenderHandler(async (event) => {
// If we error on rendering error page, we bail out and directly return to the error handler
if (!_rendered) { return }

await ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered })

if (ssrContext['~renderResponse']) {
return ssrContext['~renderResponse']
}

if (ssrContext.redirected || event.node.res.writableEnded) {
return
}
Expand All @@ -207,8 +214,6 @@ export default defineRenderHandler(async (event) => {
throw ssrContext.nuxt.error
}

ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered })

ssrContext.nuxt = ssrContext.nuxt || {}

if (process.env.NUXT_FULL_STATIC) {
Expand Down
14 changes: 14 additions & 0 deletions playground/pages/cookie-with-redirect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<p>cookie-with-redirect.vue</p>
</template>

<script setup>
definePageMeta({
middleware: defineNuxtRouteMiddleware(() => {
useCookie('redirect-test').value = 'foo'

// Immediately redirect during SSR
return navigateTo('/')
})
})
</script>
14 changes: 14 additions & 0 deletions test/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ describe('nuxt composables', () => {
expect(await extractCookie()).toEqual({ foo: 'baz' })
await page.close()
})

it('should respond with set-cookie header even when SSR redirect occurs', async () => {
const res = await fetch('/cookie-with-redirect', { redirect: 'manual' })

// Verify redirect occurred
expect(res.status).toBe(302)
expect(res.headers.get('location')).toEqual('/')

// Verify cookies were set
const cookies = res.headers.get('set-cookie')
expect(cookies).toBeTruthy()
expect(cookies).toContain('redirect-test=foo')
})

it('error should be render', async () => {
const html = await $fetch('/async-data')

Expand Down
Loading