diff --git a/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx b/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx index 08d5d22ba1f5c..cff421c36516c 100644 --- a/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx +++ b/docs/02-app/01-building-your-application/02-data-fetching/02-server-actions-and-mutations.mdx @@ -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'], }, }, } diff --git a/docs/02-app/02-api-reference/05-next-config-js/serverActions.mdx b/docs/02-app/02-api-reference/05-next-config-js/serverActions.mdx index 2896a41c86d9d..6d1a594816ef1 100644 --- a/docs/02-app/02-api-reference/05-next-config-js/serverActions.mdx +++ b/docs/02-app/02-api-reference/05-next-config-js/serverActions.mdx @@ -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'], }, }, } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index c89751955d905..eafba6c75866d 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -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) @@ -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) { diff --git a/packages/next/src/server/app-render/csrf-protection.test.ts b/packages/next/src/server/app-render/csrf-protection.test.ts new file mode 100644 index 0000000000000..f7c515395db39 --- /dev/null +++ b/packages/next/src/server/app-render/csrf-protection.test.ts @@ -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) + }) +}) diff --git a/packages/next/src/server/app-render/csrf-protection.ts b/packages/next/src/server/app-render/csrf-protection.ts new file mode 100644 index 0000000000000..f78ecc5cf782d --- /dev/null +++ b/packages/next/src/server/app-render/csrf-protection.ts @@ -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)) + ) +} diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 7ba9a6ad3bb7c..748311ac03804 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -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[] }