Skip to content

Commit

Permalink
allow passing wildcard domains in serverActions.allowedDomains (#59428)
Browse files Browse the repository at this point in the history
Implementation of feature request opened here -
#59427

Approach:

~~We are using micromatch in the csrf protection step of actionHandler
to allow for wildcard domains passed in allowedDomains. This is the same
library used for matching domains for remote images.~~

If any of the allowed domains match the origin of the request, we skip
the downstream error thrown for csrf protection.

Edit:

Micromatch is not available in this context as it is only compatible
with Node. This codepath can be run from the edge, so we need to rely on
vanilla js compatible code only.

Instead of falling back to allowing the user to pass in a regex, which
can be somewhat insecure, we opt into continuing to use a wildcard
pattern from a configuration standpoint and instead use a simple
function that matches on wildcards using string comparison and
iteration.

Ideally, Micromatch can be retrofitted to work in non-Node settings and
this piece of code can be replaced in the future, without deprecating or
changing the next.config interface.

---------

Co-authored-by: Josh Story <story@hey.com>
  • Loading branch information
akawalsky and gnoff authored Dec 12, 2023
1 parent 5adacb6 commit 6fbff29
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 4 deletions.
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

0 comments on commit 6fbff29

Please sign in to comment.