Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix internal route redirection with absolute urls outside basePath #64604

Merged
merged 5 commits into from
Jun 18, 2024
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
49 changes: 40 additions & 9 deletions packages/next/src/server/app-render/action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,36 @@ async function createForwardedActionResponse(
return RenderResult.fromStatic('{}')
}

/**
* Returns the parsed redirect URL if we deem that it is hosted by us.
*
* We handle both relative and absolute redirect URLs.
*
* In case the redirect URL is not relative to the application we return `null`.
*/
function getAppRelativeRedirectUrl(
basePath: string,
host: Host,
redirectUrl: string
): URL | null {
if (redirectUrl.startsWith('/')) {
// Make sure we are appending the basePath to relative URLS
return new URL(`${basePath}${redirectUrl}`, 'http://n')
}

const parsedRedirectUrl = new URL(redirectUrl)

if (host?.value !== parsedRedirectUrl.host) {
return null
}

// At this point the hosts are the same, just confirm we
// are routing to a path underneath the `basePath`
return parsedRedirectUrl.pathname.startsWith(basePath)
? parsedRedirectUrl
: null
}

async function createRedirectRenderResult(
req: BaseNextRequest,
res: BaseNextResponse,
Expand All @@ -252,14 +282,15 @@ async function createRedirectRenderResult(
// If we're redirecting to another route of this Next.js application, we'll
// try to stream the response from the other worker path. When that works,
// we can save an extra roundtrip and avoid a full page reload.
// When the redirect URL starts with a `/`, or to the same host as application,
// we treat it as an app-relative redirect.
const parsedRedirectUrl = new URL(redirectUrl, 'http://n')
const isAppRelativeRedirect =
redirectUrl.startsWith('/') ||
(originalHost && originalHost.value === parsedRedirectUrl.host)

if (isAppRelativeRedirect) {
// When the redirect URL starts with a `/` or is to the same host, under the
// `basePath` we treat it as an app-relative redirect;
const appRelativeRedirectUrl = getAppRelativeRedirectUrl(
basePath,
originalHost,
redirectUrl
)

if (appRelativeRedirectUrl) {
if (!originalHost) {
throw new Error(
'Invariant: Missing `host` header from a forwarded Server Actions request.'
Expand All @@ -278,7 +309,7 @@ async function createRedirectRenderResult(
process.env.__NEXT_PRIVATE_ORIGIN || `${proto}://${originalHost.value}`

const fetchUrl = new URL(
`${origin}${basePath}${parsedRedirectUrl.pathname}${parsedRedirectUrl.search}`
`${origin}${appRelativeRedirectUrl.pathname}${appRelativeRedirectUrl.search}`
Copy link
Contributor Author

Choose a reason for hiding this comment

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

getAppRelativeRedirectUrl is now responsible for including the basePath for both relative and absolute redirect urls.

)

if (staticGenerationStore.revalidatedTags) {
Expand Down
131 changes: 94 additions & 37 deletions test/e2e/app-dir/actions/app-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,25 +809,61 @@ describe('app-dir action handling', () => {
}, 'Prefix: HELLO, WORLD')
})

it('should handle redirect to a relative URL in a single pass', async () => {
const browser = await next.browser('/client/edge')
it.each(['relative', 'absolute'])(
`should handle calls to redirect() with a %s URL in a single pass`,
async (redirectType) => {
const initialPagePath = '/client/redirects'
const destinationPagePath = '/redirect-target'

await waitFor(3000)
const browser = await next.browser(initialPagePath)

let requests = []
const requests: Request[] = []
const responses: Response[] = []

browser.on('request', (req: Request) => {
requests.push(new URL(req.url()).pathname)
})
browser.on('request', (req: Request) => {
const url = req.url()

await browser.elementByCss('#redirect').click()
if (
url.includes(initialPagePath) ||
url.includes(destinationPagePath)
) {
Comment on lines +826 to +829
Copy link
Contributor Author

@cfrank cfrank Jun 7, 2024

Choose a reason for hiding this comment

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

These conditionals are here to avoid having to use await waitFor(3000)

Depending on the opinions of the core team we can either go with this pattern or revert back to the 3000ms wait in which case the conditionals are no longer needed.

requests.push(req)
}
})

// no other requests should be made
expect(requests).toEqual(['/client/edge'])
})
browser.on('response', (res: Response) => {
const url = res.url()

it('should handle regular redirects', async () => {
const browser = await next.browser('/client/edge')
if (
url.includes(initialPagePath) ||
url.includes(destinationPagePath)
) {
responses.push(res)
}
})

await browser.elementById(`redirect-${redirectType}`).click()
await check(() => browser.url(), `${next.url}${destinationPagePath}`)

expect(await browser.waitForElementByCss('#redirected').text()).toBe(
'redirected'
)

// no other requests should be made
expect(requests).toHaveLength(1)
expect(responses).toHaveLength(1)

const request = requests[0]
const response = responses[0]

expect(request.url()).toEqual(`${next.url}${initialPagePath}`)
expect(request.method()).toEqual('POST')
expect(response.status()).toEqual(303)
}
)

it('should handle calls to redirect() with external URLs', async () => {
const browser = await next.browser('/client/redirects')

await browser.elementByCss('#redirect-external').click()

Expand Down Expand Up @@ -876,36 +912,57 @@ describe('app-dir action handling', () => {
await check(() => browser.elementByCss('#count').text(), '2')
})

it('should handle redirect to a relative URL in a single pass', async () => {
let responseCode: number
const browser = await next.browser('/client', {
beforePageLoad(page) {
page.on('response', async (res: Response) => {
const headers = await res.allHeaders()
if (headers['x-action-redirect']) {
responseCode = res.status()
}
})
},
})
it.each(['relative', 'absolute'])(
`should handle calls to redirect() with a %s URL in a single pass`,
async (redirectType) => {
const initialPagePath = '/client/redirects'
const destinationPagePath = '/redirect-target'

await waitFor(3000)
const browser = await next.browser(initialPagePath)

let requests = []
const requests: Request[] = []
const responses: Response[] = []

browser.on('request', (req: Request) => {
requests.push(new URL(req.url()).pathname)
})
browser.on('request', (req: Request) => {
const url = req.url()

await browser.elementByCss('#redirect').click()
if (
url.includes(initialPagePath) ||
url.includes(destinationPagePath)
) {
requests.push(req)
}
})

// no other requests should be made
expect(requests).toEqual(['/client'])
await check(() => responseCode, 303)
})
browser.on('response', (res: Response) => {
const url = res.url()

if (
url.includes(initialPagePath) ||
url.includes(destinationPagePath)
) {
responses.push(res)
}
})

await browser.elementById(`redirect-${redirectType}`).click()
await check(() => browser.url(), `${next.url}${destinationPagePath}`)

// no other requests should be made
expect(requests).toHaveLength(1)
expect(responses).toHaveLength(1)

const request = requests[0]
const response = responses[0]

expect(request.url()).toEqual(`${next.url}${initialPagePath}`)
expect(request.method()).toEqual('POST')
expect(response.status()).toEqual(303)
}
)

it('should handle regular redirects', async () => {
const browser = await next.browser('/client')
it('should handle calls to redirect() with external URLs', async () => {
const browser = await next.browser('/client/redirects')

await browser.elementByCss('#redirect-external').click()

Expand Down
14 changes: 12 additions & 2 deletions test/e2e/app-dir/actions/app/client/edge/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,20 @@ export default function Counter() {
</button>
<form>
<button
id="redirect"
id="redirect-relative"
formAction={() => redirectAction('/redirect-target')}
>
redirect
redirect to a relative URL
</button>
</form>
<form>
<button
id="redirect-absolute"
formAction={() =>
redirectAction(`${location.origin}/redirect-target`)
}
>
redirect to a absolute URL
</button>
</form>
<form>
Expand Down
30 changes: 0 additions & 30 deletions test/e2e/app-dir/actions/app/client/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,6 @@ export default function Counter() {
>
*2
</button>
<form>
<button
id="redirect"
formAction={() => redirectAction('/redirect-target')}
>
redirect
</button>
</form>
<form>
<button
id="redirect-external"
formAction={() =>
redirectAction(
'https://next-data-api-endpoint.vercel.app/api/random?page'
)
}
>
redirect external
</button>
</form>
<form>
<button
id="redirect-absolute"
formAction={() =>
redirectAction(location.origin + '/redirect-target')
}
>
redirect internal with domain
</button>
</form>
<form>
<button
id="redirect-pages"
Expand Down
40 changes: 40 additions & 0 deletions test/e2e/app-dir/actions/app/client/redirects/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client'

import { redirectAction } from '../actions'

export default function Page() {
return (
<div>
<form>
<button
id="redirect-relative"
formAction={() => redirectAction('/redirect-target')}
>
redirect relative
</button>
</form>
<form>
<button
id="redirect-external"
formAction={() =>
redirectAction(
'https://next-data-api-endpoint.vercel.app/api/random?page'
)
}
>
redirect external
</button>
</form>
<form>
<button
id="redirect-absolute"
formAction={() =>
redirectAction(location.origin + '/redirect-target')
}
>
redirect internal with domain
</button>
</form>
</div>
)
}
9 changes: 9 additions & 0 deletions test/e2e/app-dir/app-basepath/app/client/action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use server'

import 'server-only'

import { redirect } from 'next/navigation'

export async function redirectAction(path) {
redirect(path)
}
36 changes: 36 additions & 0 deletions test/e2e/app-dir/app-basepath/app/client/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client'

import { redirectAction } from './action'

export default function Page() {
return (
<div>
<form>
<button
id="redirect-relative"
formAction={() => redirectAction('/another')}
>
redirect internal with relative path
</button>
</form>
<form>
<button
id="redirect-absolute-internal"
formAction={() => redirectAction(location.origin + '/base/another')}
>
redirect internal with domain (including basePath)
</button>
</form>
<form>
<button
id="redirect-absolute-external"
formAction={() =>
redirectAction(location.origin + '/outsideBasePath')
}
>
redirect external with domain (excluding basePath)
</button>
</form>
</div>
)
}
Loading
Loading