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

allow passing wildcard domains in serverActions.allowedDomains #59428

Merged
merged 9 commits into from
Dec 12, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ For large applications that use reverse proxies or multi-layered backend archite
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com'],
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A list of extra safe origin domains from which Server Actions can be invoked. Ne
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com'],
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/server/app-render/action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
getIsServerAction,
getServerActionRequestMetadata,
} from '../lib/server-action-request-meta'
import { isCsrfOriginAllowed } from './csrf-protection'

function formDataFromSearchQueryString(query: string) {
const searchParams = new URLSearchParams(query)
Expand Down Expand Up @@ -331,7 +332,7 @@ export async function handleAction({
// 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)) {
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
// Ignore it
} else {
if (host) {
Expand Down
42 changes: 42 additions & 0 deletions packages/next/src/server/app-render/csrf-protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { isCsrfOriginAllowed } from './csrf-protection'

describe('isCsrfOriginAllowed', () => {
it('should return true when allowedOrigins contains originDomain', () => {
expect(isCsrfOriginAllowed('vercel.com', ['vercel.com'])).toBe(true)
expect(isCsrfOriginAllowed('www.vercel.com', ['www.vercel.com'])).toBe(true)
})

it('should return true when allowedOrigins contains originDomain with matching pattern', () => {
expect(isCsrfOriginAllowed('asdf.vercel.com', ['*.vercel.com'])).toBe(true)
expect(isCsrfOriginAllowed('asdf.vercel.com', ['**.vercel.com'])).toBe(true)
expect(isCsrfOriginAllowed('asdf.jkl.vercel.com', ['**.vercel.com'])).toBe(
true
)
})

it('should return false when allowedOrigins contains originDomain with non-matching pattern', () => {
expect(isCsrfOriginAllowed('asdf.vercel.com', ['*.vercel.app'])).toBe(false)
expect(isCsrfOriginAllowed('asdf.jkl.vercel.com', ['*.vercel.com'])).toBe(
false
)
expect(isCsrfOriginAllowed('asdf.jkl.vercel.app', ['**.vercel.com'])).toBe(
false
)
})

it('should return false when allowedOrigins does not contain originDomain', () => {
expect(isCsrfOriginAllowed('vercel.com', ['nextjs.org'])).toBe(false)
})

it('should return false when allowedOrigins is undefined', () => {
expect(isCsrfOriginAllowed('vercel.com', undefined)).toBe(false)
})

it('should return false when allowedOrigins is empty', () => {
expect(isCsrfOriginAllowed('vercel.com', [])).toBe(false)
})

it('should return false when allowedOrigins is empty string', () => {
expect(isCsrfOriginAllowed('vercel.com', [''])).toBe(false)
})
})
88 changes: 88 additions & 0 deletions packages/next/src/server/app-render/csrf-protection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// micromatch is only available at node runtime, so it cannot be used here since the code path that calls this function
// can be run from edge. This is a simple implementation that safely achieves the required functionality.
// the goal is to match the functionality for remotePatterns as defined here -
// https://nextjs.org/docs/app/api-reference/components/image#remotepatterns
// TODO - retrofit micromatch to work in edge and use that instead
function matchWildcardDomain(domain: string, pattern: string) {
const domainParts = domain.split('.')
const patternParts = pattern.split('.')

if (patternParts.length < 1) {
// pattern is empty and therefore invalid to match against
return false
}

if (domainParts.length < patternParts.length) {
// domain has too few segments and thus cannot match
return false
}

let depth = 0
while (patternParts.length && depth++ < 2) {
const patternPart = patternParts.pop()
const domainPart = domainParts.pop()

switch (patternPart) {
case '':
case '*':
case '**': {
// invalid pattern. pattern segments must be non empty
// Additionally wildcards are only supported below the domain level
return false
}
default: {
if (domainPart !== patternPart) {
return false
}
}
}
}

while (patternParts.length) {
const patternPart = patternParts.pop()
const domainPart = domainParts.pop()

switch (patternPart) {
case '': {
// invalid pattern. pattern segments must be non empty
return false
}
case '*': {
// wildcard matches anything so we continue if the domain part is non-empty
if (domainPart) {
continue
} else {
return false
}
}
case '**': {
// if this is not the last item in the pattern the pattern is invalid
if (patternParts.length > 0) {
return false
}
// recursive wildcard matches anything so we terminate here if the domain part is non empty
return domainPart !== undefined
}
default: {
if (domainPart !== patternPart) {
return false
}
}
}
}

// We exhausted the pattern. If we also exhausted the domain we have a match
return domainParts.length === 0
}

export const isCsrfOriginAllowed = (
originDomain: string,
allowedOrigins: string[] = []
): boolean => {
return allowedOrigins.some(
(allowedOrigin) =>
allowedOrigin &&
(allowedOrigin === originDomain ||
matchWildcardDomain(originDomain, allowedOrigin))
)
}
2 changes: 1 addition & 1 deletion packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ export interface ExperimentalConfig {
* 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-app.com"]
* ["my-app.com", "*.my-app.com"]
*/
allowedOrigins?: string[]
}
Expand Down