Change allowed forwarded hosts to be allowed origins for Server Actions (#58023)
The allowlist should be origin domains that are allowed to send the requests, not the list of forwarded hosts (i.e. reverse proxies).
This commit is contained in:
parent
f7b979c9cf
commit
24a617c24f
20 changed files with 142 additions and 50 deletions
|
@ -28,7 +28,7 @@ export type EdgeSSRLoaderQuery = {
|
|||
middlewareConfig: string
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ export function getRender({
|
|||
serverActionsManifest?: any
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
config: NextConfigComplete
|
||||
buildId: string
|
||||
|
|
|
@ -79,7 +79,7 @@ const supportedTurbopackNextConfigOptions = [
|
|||
'experimental.scrollRestoration',
|
||||
'experimental.forceSwcTransforms',
|
||||
'experimental.serverActions.bodySizeLimit',
|
||||
'experimental.serverActions.allowedForwardedHosts',
|
||||
'experimental.serverActions.allowedOrigins',
|
||||
'experimental.memoryBasedWorkersCount',
|
||||
'experimental.clientRouterFilterRedirects',
|
||||
'experimental.webpackBuildWorker',
|
||||
|
|
|
@ -257,7 +257,7 @@ export async function handleAction({
|
|||
requestStore: RequestStore
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
ctx: AppRenderContext
|
||||
}): Promise<
|
||||
|
@ -288,7 +288,7 @@ export async function handleAction({
|
|||
return
|
||||
}
|
||||
|
||||
const originHostname =
|
||||
const originDomain =
|
||||
typeof req.headers['origin'] === 'string'
|
||||
? new URL(req.headers['origin']).host
|
||||
: undefined
|
||||
|
@ -311,29 +311,28 @@ export async function handleAction({
|
|||
|
||||
// This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
|
||||
// ensure that the request is coming from the same host.
|
||||
if (!originHostname) {
|
||||
if (!originDomain) {
|
||||
// This might be an old browser that doesn't send `host` header. We ignore
|
||||
// this case.
|
||||
console.warn(
|
||||
'Missing `origin` header from a forwarded Server Actions request.'
|
||||
)
|
||||
} else if (!host || originHostname !== host.value) {
|
||||
// If the customer sets a list of allowed hosts, we'll allow the request.
|
||||
// These can be their reverse proxies or other safe hosts.
|
||||
if (
|
||||
host &&
|
||||
typeof host.value === 'string' &&
|
||||
serverActions?.allowedForwardedHosts?.includes(host.value)
|
||||
) {
|
||||
} else if (!host || originDomain !== host.value) {
|
||||
// If the customer sets a list of allowed origins, we'll allow the request.
|
||||
// These are considered safe but might be different from forwarded host set
|
||||
// by the infra (i.e. reverse proxies).
|
||||
if (serverActions?.allowedOrigins?.includes(originDomain)) {
|
||||
// Ignore it
|
||||
} else {
|
||||
if (host) {
|
||||
// This is an attack. We should not proceed the action.
|
||||
// This seems to be an CSRF attack. We should not proceed the action.
|
||||
console.error(
|
||||
`\`${!host.type}\` header with value \`${limitUntrustedHeaderValueForLogs(
|
||||
`\`${
|
||||
host.type
|
||||
}\` header with value \`${limitUntrustedHeaderValueForLogs(
|
||||
host.value
|
||||
)}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs(
|
||||
originHostname
|
||||
originDomain
|
||||
)}\` from a forwarded Server Actions request. Aborting the action.`
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -134,9 +134,8 @@ export interface RenderOptsPartial {
|
|||
) => Promise<NextConfigComplete>
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
allowedForwardedHosts?: string[]
|
||||
params?: ParsedUrlQuery
|
||||
isPrefetch?: boolean
|
||||
experimental: { ppr: boolean }
|
||||
|
|
|
@ -250,7 +250,7 @@ type BaseRenderOpts = {
|
|||
clientReferenceManifest?: ClientReferenceManifest
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
serverActionsManifest?: any
|
||||
nextFontManifest?: NextFontManifest
|
||||
|
|
|
@ -254,10 +254,9 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
|
|||
serverActions: z
|
||||
.object({
|
||||
bodySizeLimit: zSizeLimit.optional(),
|
||||
allowedForwardedHosts: z.array(z.string()).optional(),
|
||||
allowedOrigins: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
allowedForwardedHosts: z.array(z.string()).optional(),
|
||||
// The original type was Record<string, any>
|
||||
extensionAlias: z.record(z.string(), z.any()).optional(),
|
||||
externalDir: z.boolean().optional(),
|
||||
|
|
|
@ -309,11 +309,12 @@ export interface ExperimentalConfig {
|
|||
bodySizeLimit?: SizeLimit
|
||||
|
||||
/**
|
||||
* Allowed domains that can bypass CSRF check.
|
||||
* Allowed origins that can bypass Server Action's CSRF check. This is helpful
|
||||
* when you have reverse proxy in front of your app.
|
||||
* @example
|
||||
* ["my-reverse-proxy.com"]
|
||||
* ["my-app.com"]
|
||||
*/
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -275,9 +275,8 @@ export type RenderOptsPartial = {
|
|||
serverComponents?: boolean
|
||||
serverActions?: {
|
||||
bodySizeLimit?: SizeLimit
|
||||
allowedForwardedHosts?: string[]
|
||||
allowedOrigins?: string[]
|
||||
}
|
||||
allowedForwardedHosts?: string[]
|
||||
customServer?: boolean
|
||||
crossOrigin?: 'anonymous' | 'use-credentials' | '' | undefined
|
||||
images: ImageConfigComplete
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { createNextDescribe } from 'e2e-utils'
|
||||
import { check } from 'next-test-utils'
|
||||
import { join } from 'path'
|
||||
|
||||
createNextDescribe(
|
||||
'app-dir action allowed origins',
|
||||
{
|
||||
files: join(__dirname, 'safe-origins'),
|
||||
skipDeployment: true,
|
||||
dependencies: {
|
||||
react: 'latest',
|
||||
'react-dom': 'latest',
|
||||
'server-only': 'latest',
|
||||
},
|
||||
// An arbitrary & random port.
|
||||
forcedPort: '41831',
|
||||
},
|
||||
({ next }) => {
|
||||
it('should pass if localhost is set as a safe origin', async function () {
|
||||
const browser = await next.browser('/')
|
||||
|
||||
await browser.elementByCss('button').click()
|
||||
|
||||
await check(async () => {
|
||||
return await browser.elementByCss('#res').text()
|
||||
}, 'hi')
|
||||
})
|
||||
}
|
||||
)
|
|
@ -1,10 +1,11 @@
|
|||
import { createNextDescribe } from 'e2e-utils'
|
||||
import { check } from 'next-test-utils'
|
||||
import { join } from 'path'
|
||||
|
||||
createNextDescribe(
|
||||
'app-dir action allowed fowarding hosts',
|
||||
'app-dir action disallowed origins',
|
||||
{
|
||||
files: __dirname,
|
||||
files: join(__dirname, 'unsafe-origins'),
|
||||
skipDeployment: true,
|
||||
dependencies: {
|
||||
react: 'latest',
|
||||
|
@ -13,10 +14,10 @@ createNextDescribe(
|
|||
},
|
||||
},
|
||||
({ next }) => {
|
||||
it('should error if setting an invalid x-forwarded-host header', async function () {
|
||||
const browser = await next.browser('/safe-hosts')
|
||||
// Origin should be localhost
|
||||
it('should error if x-forwarded-host does not match the origin', async function () {
|
||||
const browser = await next.browser('/')
|
||||
|
||||
await browser.eval(`window.__override_forwarded_host = 'bad.com'`)
|
||||
await browser.elementByCss('button').click()
|
||||
|
||||
await check(async () => {
|
||||
|
@ -28,16 +29,5 @@ createNextDescribe(
|
|||
: 'no'
|
||||
}, 'yes')
|
||||
})
|
||||
|
||||
it('should pass if using an allowed host', async function () {
|
||||
const browser = await next.browser('/safe-hosts')
|
||||
|
||||
await browser.eval(`window.__override_forwarded_host = 'safe.com'`)
|
||||
await browser.elementByCss('button').click()
|
||||
|
||||
await check(() => {
|
||||
return browser.elementByCss('#res').text()
|
||||
}, 'hi')
|
||||
})
|
||||
}
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
|
@ -7,12 +7,12 @@ if (typeof window !== 'undefined') {
|
|||
// hijack fetch
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function (url, init) {
|
||||
if (window.__override_forwarded_host && init?.method === 'POST') {
|
||||
if (init?.method === 'POST') {
|
||||
console.log('fetch', url, init)
|
||||
|
||||
// override forwarded host
|
||||
init.headers = init.headers || {}
|
||||
init.headers['x-forwarded-host'] = window.__override_forwarded_host
|
||||
init.headers['x-forwarded-host'] = 'my-proxy.com'
|
||||
}
|
||||
|
||||
return originalFetch(url, init)
|
|
@ -0,0 +1,12 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
productionBrowserSourceMaps: true,
|
||||
logging: {
|
||||
fetches: {},
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:41831'],
|
||||
},
|
||||
},
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
'use server'
|
||||
|
||||
export async function log() {
|
||||
return 'hi'
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { log } from './action'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// hijack fetch
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function (url, init) {
|
||||
if (init?.method === 'POST') {
|
||||
console.log('fetch', url, init)
|
||||
|
||||
// override forwarded host
|
||||
init.headers = init.headers || {}
|
||||
init.headers['x-forwarded-host'] = 'my-proxy.com'
|
||||
}
|
||||
|
||||
return originalFetch(url, init)
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [res, setRes] = useState(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="res">{res}</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setRes(await log())
|
||||
} catch (err) {
|
||||
setRes(err.message)
|
||||
}
|
||||
}}
|
||||
>
|
||||
fetch
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
productionBrowserSourceMaps: true,
|
||||
logging: {
|
||||
fetches: {},
|
||||
},
|
||||
}
|
|
@ -4,9 +4,4 @@ module.exports = {
|
|||
logging: {
|
||||
fetches: {},
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedForwardedHosts: ['safe.com'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue