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

Support overriding request headers in middlewares #41380

Merged
merged 15 commits into from Oct 20, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/next/server/next-server.ts
Expand Up @@ -1908,6 +1908,37 @@ export default class NextNodeServer extends BaseServer {
result.response.headers.set('x-middleware-rewrite', rel)
}

if (result.response.headers.has('x-middleware-override-headers')) {
const overriddenHeaders: Set<string> = new Set()
for (const key of result.response.headers
.get('x-middleware-override-headers')!
.split(',')) {
overriddenHeaders.add(key.trim())
}

result.response.headers.delete('x-middleware-override-headers')

// Delete headers.
for (const key of Object.keys(req.headers)) {
if (!overriddenHeaders.has(key)) {
req.headers[key] = undefined
}
}

// Update or add headers.
for (const key of overriddenHeaders.keys()) {
const valueKey = 'x-middleware-request-' + key
const newValue = result.response.headers.get(valueKey)
const oldValue = req.headers[key]

if (oldValue !== newValue) {
req.headers[key] = newValue === null ? undefined : newValue
}

result.response.headers.delete(valueKey)
}
}
nuta marked this conversation as resolved.
Show resolved Hide resolved

if (result.response.headers.has('Location')) {
const value = result.response.headers.get('Location')!
const rel = relativizeURL(value, initUrl)
Expand Down
44 changes: 42 additions & 2 deletions packages/next/server/web/spec-extension/response.ts
Expand Up @@ -7,6 +7,25 @@ import { NextCookies } from './cookies'
const INTERNALS = Symbol('internal response')
const REDIRECTS = new Set([301, 302, 303, 307, 308])

function handleMiddlewareField(
init: MiddlewareResponseInit | undefined,
headers: Headers
) {
if (init?.request?.headers) {
if (!(init.request.headers instanceof Headers)) {
throw new Error('request.headers must be an instance of Headers')
}
nuta marked this conversation as resolved.
Show resolved Hide resolved

const keys = []
for (const [key, value] of init.request.headers) {
headers.set('x-middleware-request-' + key, value)
keys.push(key)
}

headers.set('x-middleware-override-headers', keys.join(','))
}
}

export class NextResponse extends Response {
[INTERNALS]: {
cookies: NextCookies
Expand Down Expand Up @@ -71,15 +90,22 @@ export class NextResponse extends Response {
})
}

static rewrite(destination: string | NextURL | URL, init?: ResponseInit) {
static rewrite(
destination: string | NextURL | URL,
init?: MiddlewareResponseInit
) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-rewrite', validateURL(destination))

handleMiddlewareField(init, headers)
ijjk marked this conversation as resolved.
Show resolved Hide resolved
return new NextResponse(null, { ...init, headers })
}

static next(init?: ResponseInit) {
static next(init?: MiddlewareResponseInit) {
const headers = new Headers(init?.headers)
headers.set('x-middleware-next', '1')

handleMiddlewareField(init, headers)
return new NextResponse(null, { ...init, headers })
}
}
Expand All @@ -92,3 +118,17 @@ interface ResponseInit extends globalThis.ResponseInit {
}
url?: string
}

interface ModifiedRequest {
/**
* If this is set, the request headers will be overridden with this value.
*/
headers?: Headers
}

interface MiddlewareResponseInit extends globalThis.ResponseInit {
/**
* These fields will override the request from clients.
*/
request?: ModifiedRequest
}
@@ -0,0 +1 @@
.vercel
30 changes: 30 additions & 0 deletions test/e2e/middleware-request-header-manipulation/app/middleware.js
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'

/**
* @param {import('next/server').NextRequest} request
*/
export async function middleware(request) {
const headers = new Headers(request.headers)
headers.set('x-from-middleware', 'hello-from-middleware')

const removeHeaders = request.nextUrl.searchParams.get('remove-headers')
if (removeHeaders) {
for (const key of removeHeaders.split(',')) {
headers.delete(key)
}
}

const updateHeader = request.nextUrl.searchParams.get('update-headers')
if (updateHeader) {
for (const kv of updateHeader.split(',')) {
const [key, value] = kv.split('=')
headers.set(key, value)
}
}

return NextResponse.next({
request: {
headers,
},
})
}
27 changes: 27 additions & 0 deletions test/e2e/middleware-request-header-manipulation/app/next.config.js
@@ -0,0 +1,27 @@
module.exports = {
i18n: {
locales: ['ja', 'en', 'fr', 'es'],
defaultLocale: 'en',
},
rewrites() {
return {
beforeFiles: [
{
source: '/beforefiles-rewrite',
destination: '/ab-test/a',
},
],
afterFiles: [
{
source: '/afterfiles-rewrite',
destination: '/ab-test/b',
},
{
source: '/afterfiles-rewrite-ssg',
destination: '/fallback-true-blog/first',
},
],
fallback: [],
}
},
}
nuta marked this conversation as resolved.
Show resolved Hide resolved
@@ -0,0 +1,11 @@
export const config = {
runtime: 'experimental-edge',
}

export default (req) => {
return Response.json(Object.fromEntries(req.headers.entries()), {
headers: {
'headers-from-edge-function': '1',
},
})
}
@@ -0,0 +1,6 @@
export default (req, res) => {
return res
.status(200)
.setHeader('headers-from-serverless', '1')
.json(req.headers)
}
@@ -0,0 +1,11 @@
export default function SSRPage(props) {
return <h1>{props.message}</h1>
}

export const getServerSideProps = (req) => {
return {
props: {
message: 'Hello World',
},
}
}
nuta marked this conversation as resolved.
Show resolved Hide resolved
102 changes: 102 additions & 0 deletions test/e2e/middleware-request-header-manipulation/test/index.test.ts
@@ -0,0 +1,102 @@
/* eslint-env jest */

import { join } from 'path'
import { NextInstance } from 'test/lib/next-modes/base'
import { fetchViaHTTP } from 'next-test-utils'
import { createNext, FileRef } from 'e2e-utils'

describe('Middleware Request Headers Manipulation', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a similar test for test/e2e/app-dir? The new router relies on certain headers to be available and this change might cause accidentally breaking the application if it doesn't copy them over, especially in the Response.next( case

Copy link
Contributor Author

@nuta nuta Oct 14, 2022

Choose a reason for hiding this comment

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

Added app-based tests app-middleware:

app-middleware
├── app
│   ├── layout.js
│   └── ssr-page.server.js
├── middleware.js
├── next.config.js
└── pages
    └── api
        ├── dump-headers-edge.js
        └── dump-headers-serverless.js

newbie question: Is gSSP like the following available in the Layout RFC? Wonder if a SSR page can still read request headers. If not I'll just remove this test.

export default function SSRPage({ headers }) {
  return (
    <>
      <p id="headers">{JSON.stringify(headers)}</p>
    </>
  )
}

export const getServerSideProps = (ctx) => {
  return {
    props: {
      headers: ctx.req.headers,
    },
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nvm I noticed next/headers in the latest canary. Will update the failing test 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the test. It's ready for review 👍

let next: NextInstance

afterAll(() => next.destroy())
beforeAll(async () => {
next = await createNext({
files: {
pages: new FileRef(join(__dirname, '../app/pages')),
'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')),
'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')),
},
})
})

describe.each([
{ title: 'Serverless Functions', apiPath: '/api/dump-headers-serverless' },
{ title: 'Edge Functions', apiPath: '/api/dump-headers-edge' },
])('$title Backend', ({ apiPath }) => {
it(`Adds new headers`, async () => {
const res = await fetchViaHTTP(next.url, apiPath, null, {
headers: {
'x-from-client': 'hello-from-client',
},
})
expect(await res.json()).toMatchObject({
'x-from-client': 'hello-from-client',
'x-from-middleware': 'hello-from-middleware',
})
})

it(`Deletes headers`, async () => {
const res = await fetchViaHTTP(
next.url,
apiPath,
{
'remove-headers': 'x-from-client1,x-from-client2',
},
{
headers: {
'x-from-client1': 'hello-from-client',
'X-From-Client2': 'hello-from-client',
},
}
)

const json = await res.json()
expect(json).not.toHaveProperty('x-from-client1')
expect(json).not.toHaveProperty('X-From-Client2')
expect(json).toMatchObject({
'x-from-middleware': 'hello-from-middleware',
})

// Should not be included in response headers.
expect(res.headers.get('x-middleware-override-headers')).toBeNull()
expect(
res.headers.get('x-middleware-request-x-from-middleware')
).toBeNull()
expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull()
expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull()
})

it(`Updates headers`, async () => {
const res = await fetchViaHTTP(
next.url,
apiPath,
{
'update-headers':
'x-from-client1=new-value1,x-from-client2=new-value2',
},
{
headers: {
'x-from-client1': 'old-value1',
'X-From-Client2': 'old-value2',
'x-from-client3': 'old-value3',
},
}
)
expect(await res.json()).toMatchObject({
'x-from-client1': 'new-value1',
'x-from-client2': 'new-value2',
'x-from-client3': 'old-value3',
'x-from-middleware': 'hello-from-middleware',
})

// Should not be included in response headers.
expect(res.headers.get('x-middleware-override-headers')).toBeNull()
expect(
res.headers.get('x-middleware-request-x-from-middleware')
).toBeNull()
expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull()
expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull()
expect(res.headers.get('x-middleware-request-x-from-client3')).toBeNull()
})
})
})