Skip to content

Commit e5dee17

Browse files
authored
Enforce absolute URLs in Edge Functions runtime (#33410)
We currently have inconsistencies when working with URLs in the Edge Functions runtime, this PR addresses them introducing a warning for inconsistent usage that will break in the future. Here is the reasoning. ### The Browser When we are in a browser environment there is a fixed location stored at `globalThis.location`. Then, if one tries to build a request with a relative URL it will work using that location global hostname as _base_ to construct its URL. For example: ```typescript // https://nextjs.org new Request('/test').url; // https://nextjs.org/test Response.redirect('/test').headers.get('Location'); // https://nextjs.org/test ``` However, if we attempt to run the same code from `about:blank` it would not work because the global to use as a base `String(globalThis.location)` is not a valid URL. Therefore a call to `Response.redirect('/test')` or `new Response('/test')` would fail. ### Edge Functions Runtime In Next.js Edge Functions runtime the situation is slightly different from a browser. Say that we have a root middleware (`pages/_middleware`) that gets invoked for every page. In the middleware file we expose the handler function and also define a global variable that we mutate on every request: ```typescript // pages/_middleware let count = 0; export function middleware(req: NextRequest) { console.log(req.url); count += 1; } ``` Currently we cache the module scope in the runtime so subsequent invocations would hold the same globals and the module would not be evaluated again. This would make the counter to increment for each request that the middleware handles. It is for this reason that we **can't have a global location** that changes across different invocations. Each invocation of the same function uses the same global which also holds primitives like `URL` or `Request` so changing an hypothetical `globalThis.location` per request would affect concurrent requests being handled. Then, it is not possible to use relative URLs in the same way the browser does because we don't have a global to rely on to use its host to compose a URL from a relative path. ### Why it works today We are **not** validating what is provided to, for example, `NextResponse.rewrite()` nor `NextResponse.redirect()`. We simply create a `Response` instance that adds the corresponding header for the rewrite or the redirect. Then it is **the consumer** the one that composes the final destination based on the request. Theoretically you can pass any value and it would fail on redirect but won't validate the input. Of course this is inconsistent because it doesn't make sense that `NextResponse.rewrite('/test')` works but `fetch(new NextRequest('/test'))` does not. Also we should validate what is provided. Finally, we want to be consistent with the way a browser behaves so `new Request('/test')` _should_ not work if there is no global location which we lack. ### What this PR does We will have to deprecate the usage of relative URLs in the previously mentioned scenarios. In preparation for it, this PR adds a validation function in those places where it will break in the future, printing a warning with a link that points to a Next.js page with an explanation of the issue and ways to fix it. Although middleware changes are not covered by semver, we will roll this for some time to make people aware that this change is coming. Then after a reasonable period of time we can remove the warning and make the code fail when using relative URLs in the previously exposed scenarios.
1 parent 4d3b2ea commit e5dee17

File tree

15 files changed

+206
-24
lines changed

15 files changed

+206
-24
lines changed

errors/manifest.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,10 @@
527527
{
528528
"title": "invalid-styled-jsx-children",
529529
"path": "/errors/invalid-styled-jsx-children.md"
530+
},
531+
{
532+
"title": "middleware-relative-urls",
533+
"path": "/errors/middleware-relative-urls.md"
530534
}
531535
]
532536
}

errors/middleware-relative-urls.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Middleware Relative URLs
2+
3+
#### Why This Error Occurred
4+
5+
You are using a Middleware function that uses `Response.redirect(url)`, `NextResponse.redirect(url)` or `NextResponse.rewrite(url)` where `url` is a relative or an invalid URL. Currently this will work, but building a request with `new Request(url)` or running `fetch(url)` when `url` is a relative URL will **not** work. For this reason and to bring consistency to Next.js Middleware, this behavior will be deprecated soon in favor of always using absolute URLs.
6+
7+
#### Possible Ways to Fix It
8+
9+
To fix this warning you must always pass absolute URL for redirecting and rewriting. There are several ways to get the absolute URL but the recommended way is to clone `NextURL` and mutate it:
10+
11+
```typescript
12+
import type { NextRequest } from 'next/server'
13+
import { NextResponse } from 'next/server'
14+
15+
export function middleware(request: NextRequest) {
16+
const url = request.nextUrl.clone()
17+
url.pathname = '/dest'
18+
return NextResponse.rewrite(url)
19+
}
20+
```
21+
22+
Another way to fix this error could be to use the original URL as the base but this will not consider configuration like `basePath` or `locale`:
23+
24+
```typescript
25+
import type { NextRequest } from 'next/server'
26+
import { NextResponse } from 'next/server'
27+
28+
export function middleware(request: NextRequest) {
29+
return NextResponse.rewrite(new URL('/dest', request.url))
30+
}
31+
```
32+
33+
You can also pass directly a string containing a valid absolute URL.
34+
35+
### Useful Links
36+
37+
- [URL Documentation](https://developer.mozilla.org/en-US/docs/Web/API/URL)
38+
- [Response Documentation](https://developer.mozilla.org/en-US/docs/Web/API/Response)

packages/next/server/web/next-url.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ export class NextURL {
244244
toJSON() {
245245
return this.href
246246
}
247+
248+
clone() {
249+
return new NextURL(String(this), this[Internal].options)
250+
}
247251
}
248252

249253
const REGEX_LOCALHOST_HOSTNAME =

packages/next/server/web/spec-compliant/response.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Body, BodyInit, cloneBody, extractContentType } from './body'
22
import { NextURL } from '../next-url'
3+
import { validateURL } from '../utils'
34

45
const INTERNALS = Symbol('internal response')
56
const REDIRECTS = new Set([301, 302, 303, 307, 308])
@@ -45,7 +46,7 @@ class BaseResponse extends Body implements Response {
4546
)
4647
}
4748

48-
return new Response(url, {
49+
return new Response(validateURL(url), {
4950
headers: { Location: url },
5051
status,
5152
})

packages/next/server/web/spec-extension/response.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { I18NConfig } from '../../config-shared'
22
import { NextURL } from '../next-url'
3-
import { toNodeHeaders } from '../utils'
3+
import { toNodeHeaders, validateURL } from '../utils'
44
import cookie from 'next/dist/compiled/cookie'
55
import { CookieSerializeOptions } from '../types'
66

@@ -79,7 +79,8 @@ export class NextResponse extends Response {
7979
'Failed to execute "redirect" on "response": Invalid status code'
8080
)
8181
}
82-
const destination = typeof url === 'string' ? url : url.toString()
82+
83+
const destination = validateURL(url)
8384
return new NextResponse(destination, {
8485
headers: { Location: destination },
8586
status,
@@ -89,10 +90,7 @@ export class NextResponse extends Response {
8990
static rewrite(destination: string | NextURL) {
9091
return new NextResponse(null, {
9192
headers: {
92-
'x-middleware-rewrite':
93-
typeof destination === 'string'
94-
? destination
95-
: destination.toString(),
93+
'x-middleware-rewrite': validateURL(destination),
9694
},
9795
})
9896
}

packages/next/server/web/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,20 @@ export function splitCookiesString(cookiesString: string) {
147147

148148
return cookiesStrings
149149
}
150+
151+
/**
152+
* We will be soon deprecating the usage of relative URLs in Middleware introducing
153+
* URL validation. This helper puts the future code in place and prints a warning
154+
* for cases where it will break. Meanwhile we preserve the previous behavior.
155+
*/
156+
export function validateURL(url: string | URL): string {
157+
try {
158+
return String(new URL(String(url)))
159+
} catch (error: any) {
160+
console.log(
161+
`warn -`,
162+
'using relative URLs for Middleware will be deprecated soon - https://nextjs.org/docs/messages/middleware-relative-urls'
163+
)
164+
return String(url)
165+
}
166+
}

test/integration/middleware/core/pages/interface/_middleware.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export async function middleware(request) {
8181
}
8282

8383
if (url.pathname.endsWith('/dynamic-replace')) {
84-
return NextResponse.rewrite('/_interface/dynamic-path')
84+
url.pathname = '/_interface/dynamic-path'
85+
return NextResponse.rewrite(url)
8586
}
8687

8788
return new Response(null, {

test/integration/middleware/core/pages/redirects/_middleware.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,6 @@ export async function middleware(request) {
5656

5757
if (url.pathname === '/redirects/infinite-loop-1') {
5858
url.pathname = '/redirects/infinite-loop'
59-
return Response.redirect(url.pathname)
59+
return Response.redirect(url)
6060
}
6161
}

test/integration/middleware/core/pages/rewrites/_middleware.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,27 @@ export async function middleware(request) {
55

66
if (url.pathname.startsWith('/rewrites/to-blog')) {
77
const slug = url.pathname.split('/').pop()
8-
console.log('rewriting to slug', slug)
9-
return NextResponse.rewrite(`/rewrites/fallback-true-blog/${slug}`)
8+
url.pathname = `/rewrites/fallback-true-blog/${slug}`
9+
return NextResponse.rewrite(url)
1010
}
1111

1212
if (url.pathname === '/rewrites/rewrite-to-ab-test') {
1313
let bucket = request.cookies.bucket
1414
if (!bucket) {
1515
bucket = Math.random() >= 0.5 ? 'a' : 'b'
16-
const response = NextResponse.rewrite(`/rewrites/${bucket}`)
16+
url.pathname = `/rewrites/${bucket}`
17+
const response = NextResponse.rewrite(url)
1718
response.cookie('bucket', bucket, { maxAge: 10000 })
1819
return response
1920
}
2021

21-
return NextResponse.rewrite(`/rewrites/${bucket}`)
22+
url.pathname = `/rewrites/${bucket}`
23+
return NextResponse.rewrite(url)
2224
}
2325

2426
if (url.pathname === '/rewrites/rewrite-me-to-about') {
25-
return NextResponse.rewrite('/rewrites/about')
27+
url.pathname = '/rewrites/about'
28+
return NextResponse.rewrite(url)
2629
}
2730

2831
if (url.pathname === '/rewrites/rewrite-me-to-vercel') {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse, NextRequest } from 'next/server'
2+
3+
export function middleware(request) {
4+
try {
5+
if (request.nextUrl.pathname === '/urls/relative-url') {
6+
return NextResponse.json({ message: String(new URL('/relative')) })
7+
}
8+
9+
if (request.nextUrl.pathname === '/urls/relative-request') {
10+
return fetch(new Request('/urls/urls-b'))
11+
}
12+
13+
if (request.nextUrl.pathname === '/urls/relative-redirect') {
14+
return Response.redirect('/urls/urls-b')
15+
}
16+
17+
if (request.nextUrl.pathname === '/urls/relative-next-redirect') {
18+
return NextResponse.redirect('/urls/urls-b')
19+
}
20+
21+
if (request.nextUrl.pathname === '/urls/relative-next-rewrite') {
22+
return NextResponse.rewrite('/urls/urls-b')
23+
}
24+
25+
if (request.nextUrl.pathname === '/urls/relative-next-request') {
26+
return fetch(new NextRequest('/urls/urls-b'))
27+
}
28+
} catch (error) {
29+
return NextResponse.json({
30+
error: {
31+
message: error.message,
32+
},
33+
})
34+
}
35+
}

0 commit comments

Comments
 (0)