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

feat(middleware)!: forbids middleware response body #36835

Merged
merged 25 commits into from
May 19, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4b1a173
feat(middleware)\!: forbid response body
feugy May 4, 2022
27380a4
chore: restores URL legit test cases
feugy May 6, 2022
cac6671
test: adds e2e tests for middleware body restriction
feugy May 6, 2022
1704bb0
doc: updates middleware and next/server pages.
feugy May 6, 2022
93621a1
feat!: removes NextResponse.json() function
feugy May 9, 2022
8d70b9e
feat: warns for middleware body response in development, fails during…
feugy May 10, 2022
f5d515a
refactor(middleware): be as strict during build as in dev
feugy May 11, 2022
719f0a6
docs: improves education about the middleware body limitation
feugy May 11, 2022
f128503
chore: removes unused code
feugy May 11, 2022
404d0d8
chore: removes unused code
feugy May 11, 2022
f0b1dd8
chore: fixes existing production tests
feugy May 11, 2022
7881769
chore: restores RSC when running on the edge
feugy May 12, 2022
26620d0
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 16, 2022
86a3b25
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 17, 2022
c49a0bc
chore: removes underscore from error page
feugy May 17, 2022
4600621
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 17, 2022
3c56212
docs: better examples
feugy May 17, 2022
bf8b175
refactor: relies on next-server to handle redirections
feugy May 18, 2022
9d4102c
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 18, 2022
33958ae
chore: fix linter issue on generated file
feugy May 18, 2022
4f83c66
refactor(middleware): logs warning and use generic 500 when setting r…
feugy May 19, 2022
3348886
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 19, 2022
073079d
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 19, 2022
265d02e
chore: remove undesired changes
feugy May 19, 2022
0ed490f
Merge branch 'canary' into feat/forbid-middleware-response-body
feugy May 19, 2022
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test/tmp/**
# Editors
**/.idea
**/.#*
.nvmrc

# examples
examples/**/out
Expand Down
2 changes: 1 addition & 1 deletion docs/advanced-features/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ npm install next@latest
import type { NextFetchEvent, NextRequest } from 'next/server'

export function middleware(req: NextRequest, ev: NextFetchEvent) {
return new Response('Hello, world!')
return new Response(null, { headers: { location: '/hello-world' } })
feugy marked this conversation as resolved.
Show resolved Hide resolved
}
```

Expand Down
18 changes: 16 additions & 2 deletions docs/api-reference/next/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ The following static methods are available on the `NextResponse` class directly:
- `redirect()` - Returns a `NextResponse` with a redirect set
- `rewrite()` - Returns a `NextResponse` with a rewrite set
- `next()` - Returns a `NextResponse` that will continue the middleware chain
- `json()` - A convenience method to create a response that encodes the provided JSON data

```ts
import { NextResponse } from 'next/server'
Expand All @@ -120,7 +119,7 @@ export function middleware(req: NextRequest) {
return NextResponse.rewrite('/not-home')
}

return NextResponse.json({ message: 'Hello World!' })
return NextResponse.next()
}
```

Expand Down Expand Up @@ -183,6 +182,21 @@ console.log(NODE_ENV)
console.log(process.env)
```

### The body limitation

When using middlewares, it is not permitted to change the response body: you can only set responses headers.
Returning a body from a middleware function will issue an `500` server error with an explicit response message.

The `NextResponse` API (which eventually is tweaking response headers) allows you to:

- redirect the incoming request to a different url
- rewrite the response by displaying a given url
- set response cookies
- set response headers

These are solid tools to implement cases such as A/B testing, authentication, feature flags, bot protection...
A middleware with the ability to change the response's body would bypass Next.js routing logic.

## Related

<div class="card">
Expand Down
4 changes: 4 additions & 0 deletions errors/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,10 @@
{
"title": "invalid-script",
"path": "/errors/invalid-script.md"
},
{
"title": "returning-response-body-in-middleware",
"path": "/errors/returning-response-body-in-middleware.md"
}
]
}
Expand Down
84 changes: 84 additions & 0 deletions errors/returning-response-body-in-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Returning response body in middleware

#### Why This Error Occurred

Your [`middleware`](https://nextjs.org/docs/advanced-features/middleware) function returns a response body, which is not supported.

Letting middleware respond to incoming requests would bypass Next.js routing mechanism, creating an unecessary escape hatch.

#### Possible Ways to Fix It

Next.js middleware gives you a great opportunity to run code and adjust to the requesting user.

It is intended for use cases like:

- A/B testing, where you **_rewrite_** to a different page based on external data (User agent, user location, a custom header or cookie...)

```js
export function middleware(req: NextRequest) {
let res = NextResponse.next()
// reuses cookie, or builds a new one.
const cookie = req.cookies.get(COOKIE_NAME) ?? buildABTestingCookie()

// the cookie contains the displayed variant, 0 being default
const [, variantId] = cookie.split('.')
if (variantId !== '0') {
const url = req.nextUrl.clone()
url.pathname = url.pathname.replace('/', `/${variantId}/`)
// rewrites the response to display desired variant
res = NextResponse.rewrite(url)
}

// don't forget to set cookie if not set yet
if (!req.cookies.has(COOKIE_NAME)) {
res.cookies.set(COOKIE_NAME, cookie)
}
return res
}
```

- authentication, where you **_redirect_** to your log-in/sign-in page any un-authenticated request

```js
export function middleware(req: NextRequest) {
const basicAuth = req.headers.get('authorization')

if (basicAuth) {
const auth = basicAuth.split(' ')[1]
const [user, pwd] = atob(auth).split(':')
if (areCredentialsValid(user, pwd)) {
return NextResponse.next()
}
}

return NextResponse.redirec(`/login?from=${req.nextUrl.pathname}`)
}
```

- detecting bots and **_rewrite_** response to display to some sink

```js
export function middleware(req: NextRequest) {
if (isABotRequest(req)) {
// Bot detected! rewrite to the sink
const url = req.nextUrl.clone()
url.pathname = '/bot-detected'
return NextResponse.rewrite(url)
}
return NextResponse.next()
}
```

- programmatically adding **_headers_** to the response, like cookies.

```js
export function middleware(req: NextRequest) {
const res = NextResponse.next(null, {
// sets a custom response header
headers: { 'response-greetings': 'Hej!' },
})
// configures cookies
response.cookies.set('hello', 'world')
return res
}
```
6 changes: 3 additions & 3 deletions packages/next/build/webpack/loaders/next-middleware-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function middlewareLoader(this: any) {
}

return `
import { adapter } from 'next/dist/server/web/adapter'
import { adapter, blockUnallowedResponse } from 'next/dist/server/web/adapter'

// The condition is true when the "process" module is provided
if (process !== global.process) {
Expand All @@ -32,11 +32,11 @@ export default function middlewareLoader(this: any) {
}

export default function (opts) {
return adapter({
return blockUnallowedResponse(adapter({
...opts,
page: ${JSON.stringify(page)},
handler,
})
}))
}
`
}
50 changes: 48 additions & 2 deletions packages/next/build/webpack/plugins/middleware-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ export default class MiddlewarePlugin {
apply(compiler: webpack5.Compiler) {
compiler.hooks.compilation.tap(NAME, (compilation, params) => {
const { hooks } = params.normalModuleFactory

/**
* This is the static code analysis phase.
*/
const codeAnalyzer = getCodeAnalizer({ dev: this.dev, compiler })
const codeAnalyzer = getCodeAnalizer({
dev: this.dev,
compiler,
compilation,
})
hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer)
hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer)
hooks.parser.for('javascript/esm').tap(NAME, codeAnalyzer)
Expand Down Expand Up @@ -93,11 +96,13 @@ export default class MiddlewarePlugin {
function getCodeAnalizer(params: {
dev: boolean
compiler: webpack5.Compiler
compilation: webpack5.Compilation
}) {
return (parser: webpack5.javascript.JavascriptParser) => {
const {
dev,
compiler: { webpack: wp },
compilation,
} = params
const { hooks } = parser

Expand Down Expand Up @@ -175,6 +180,31 @@ function getCodeAnalizer(params: {
}
}

/**
* A handler for calls to `new Response()` so we can fail if user is setting the response's body.
*/
const handleNewResponseExpression = (node: any) => {
const firstParameter = node?.arguments?.[0]
if (
isUserMiddlewareUserFile(parser.state.current) &&
firstParameter &&
!isNullLiteral(firstParameter) &&
!isUndefinedIdentifier(firstParameter)
) {
const error = new wp.WebpackError(
`Your middleware is returning a response body (line: ${node.loc.start.line}), which is not supported. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware`
)
error.name = NAME
error.module = parser.state.current
error.loc = node.loc
if (dev) {
compilation.warnings.push(error)
} else {
compilation.errors.push(error)
}
}
}

/**
* A noop handler to skip analyzing some cases.
*/
Expand All @@ -187,6 +217,8 @@ function getCodeAnalizer(params: {
hooks.call.for('global.Function').tap(NAME, handleWrapExpression)
hooks.new.for('Function').tap(NAME, handleWrapExpression)
hooks.new.for('global.Function').tap(NAME, handleWrapExpression)
hooks.new.for('Response').tap(NAME, handleNewResponseExpression)
hooks.new.for('NextResponse').tap(NAME, handleNewResponseExpression)
hooks.expression.for('eval').tap(NAME, handleExpression)
hooks.expression.for('Function').tap(NAME, handleExpression)
hooks.expression.for('global.eval').tap(NAME, handleExpression)
Expand Down Expand Up @@ -414,3 +446,17 @@ function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
)
return files
}

function isUserMiddlewareUserFile(module: any) {
return (
module.layer === 'middleware' && /_middleware\.\w+$/.test(module.rawRequest)
)
}

function isNullLiteral(expr: any) {
return expr.value === null
}

function isUndefinedIdentifier(expr: any) {
return expr.name === 'undefined'
}
39 changes: 35 additions & 4 deletions packages/next/server/web/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { NextMiddleware, RequestData, FetchEventResult } from './types'
import type {
NextMiddleware,
RequestData,
FetchEventResult,
NextMiddlewareResult,
} from './types'
import type { RequestInit } from './spec-extension/request'
import { DeprecationError } from './error'
import { fromNodeHeaders } from './utils'
import { NextFetchEvent } from './spec-extension/fetch-event'
import { NextRequest } from './spec-extension/request'
import { NextResponse } from './spec-extension/response'
import { NextResponse, RedirectHeader } from './spec-extension/response'
import { waitUntilSymbol } from './spec-compliant/fetch-event'

export async function adapter(params: {
Expand All @@ -27,14 +32,40 @@ export async function adapter(params: {
})

const event = new NextFetchEvent({ request, page: params.page })
const original = await params.handler(request, event)
const response = await params.handler(request, event)

return {
response: original || NextResponse.next(),
response: response || NextResponse.next(),
waitUntil: Promise.all(event[waitUntilSymbol]),
}
}

export function blockUnallowedResponse(
promise: Promise<FetchEventResult>
): Promise<FetchEventResult> {
return promise.then((result) => ({
...result,
response: isAllowed(result.response)
? result.response
: new Response(
JSON.stringify({
message: `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware`,
feugy marked this conversation as resolved.
Show resolved Hide resolved
}),
{
status: 500,
statusText: 'Internal Server Error',
headers: { 'content-type': 'application/json' },
}
),
}))
}

function isAllowed(response: NextMiddlewareResult): boolean {
return (
!response?.body || !response.body || response.headers.has(RedirectHeader)
feugy marked this conversation as resolved.
Show resolved Hide resolved
)
}

class NextRequestHint extends NextRequest {
sourcePage: string

Expand Down
2 changes: 1 addition & 1 deletion packages/next/server/web/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export class DeprecationError extends Error {
super(`The middleware "${page}" accepts an async API directly with the form:

export function middleware(request, event) {
return new Response("Hello " + request.url)
return new NextResponse(null, { status: 403 })
feugy marked this conversation as resolved.
Show resolved Hide resolved
}

Read more: https://nextjs.org/docs/messages/middleware-new-signature
Expand Down
14 changes: 7 additions & 7 deletions packages/next/server/web/spec-extension/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { NextCookies } from './cookies'
const INTERNALS = Symbol('internal response')
const REDIRECTS = new Set([301, 302, 303, 307, 308])

export const RedirectHeader = 'location'
export const RewriteHeader = 'x-middleware-rewrite'
export const NextMiddlewareHeader = 'x-middleware-next'

export class NextResponse extends Response {
[INTERNALS]: {
cookies: NextCookies
Expand Down Expand Up @@ -53,24 +57,20 @@ export class NextResponse extends Response {

const destination = validateURL(url)
return new NextResponse(destination, {
headers: { Location: destination },
headers: { [RedirectHeader]: destination },
status,
})
}

static rewrite(destination: string | NextURL | URL) {
return new NextResponse(null, {
headers: {
'x-middleware-rewrite': validateURL(destination),
},
headers: { [RewriteHeader]: validateURL(destination) },
})
}

static next() {
return new NextResponse(null, {
headers: {
'x-middleware-next': '1',
},
headers: { [NextMiddlewareHeader]: '1' },
})
}
}
Expand Down
Loading