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

Conversation

akawalsky
Copy link
Contributor

@akawalsky akawalsky commented Dec 9, 2023

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.

@JustinMartinDev
Copy link

JustinMartinDev commented Dec 11, 2023

Can you add and exemple with wildcard in docs

config-shared.ts:

  /**
     * 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"]
     */
    allowedOrigins?: string[]

serverActions.mdx:

module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
    },
  },
}

02-server-actions-and-mutations.mdx:

```js filename="next.config.js"
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
    },
  },
}

@akawalsky akawalsky requested review from a team as code owners December 11, 2023 15:48
@akawalsky akawalsky requested review from manovotny and lydiahallie and removed request for a team December 11, 2023 15:48
@akawalsky
Copy link
Contributor Author

@JustinMartinDev good call, added your suggestions


export const isCsrfOriginAllowed = (
originDomain: string,
allowedOrigins: string[] | undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

We should default the config to always have an allowedOrigins value, it should just be an empty array if it was omitted by the user. then we can get rid of some of the existential operators

Copy link
Contributor

Choose a reason for hiding this comment

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

If that is too invasive a change for this PR then update the function to only accept an array and then move the existence check to the callsite

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The existence check in this case is to account for TypeError: Expected pattern to be a non-empty string which appears to be thrown at runtime when the user passes a blank string as an element of allowedOrigins

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Making the array itself required would only allow for us to remove the optional chaining at the top level and the fallback to false, which I am also happy to do but wanted to point out above ^.

Copy link
Contributor

Choose a reason for hiding this comment

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

What I was suggesting is that wherever the next.config.js is processed and turned into the value passed as serverActions we do a normalization step. We make allowedOrigins an array if it doesn't exist. we error if you provided a value but it was the incorrect format. We error if you provide an empty string etc...

We could even go as far as constructing the minimatch regex there so all we have to do is call the match here.

I just took a look though and it seems this logic is pretty spread out

@@ -331,7 +332,8 @@ 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)) {
// regex is used to support wildcard domains
Copy link
Contributor

Choose a reason for hiding this comment

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

this comment is left over from when it was using regex directly I believe

@akawalsky akawalsky requested a review from gnoff December 11, 2023 16:43
@ijjk
Copy link
Member

ijjk commented Dec 11, 2023

Allow CI Workflow Run

  • approve CI run for commit: ee662fa

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

gnoff
gnoff previously approved these changes Dec 11, 2023
Copy link
Contributor

@gnoff gnoff left a comment

Choose a reason for hiding this comment

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

Can you update the PR description to describe the change

@akawalsky akawalsky changed the title allow passing regex in serverActions.allowedDomains allow passing wildcard domains in serverActions.allowedDomains Dec 11, 2023
@akawalsky
Copy link
Contributor Author

Can you update the PR description to describe the change

Yep, done

@gnoff
Copy link
Contributor

gnoff commented Dec 11, 2023

Yep, done

I mean the PR body. Currently it just links to the discussion which justifies the change but we want to also articulate the approach. i.e. you're using minimatch which is the same library used to match wildcard domains for remote images etc...

@ijjk
Copy link
Member

ijjk commented Dec 11, 2023

Tests Passed

@gnoff gnoff dismissed their stale review December 11, 2023 19:01

errors suggest something is up with bundling new dep

@gnoff
Copy link
Contributor

gnoff commented Dec 11, 2023

@akawalsky seems like micromatch is node.js only. Some of these tests are failing because it is being used in edge runtimes where transitive deps like "path" are not available. Not really sure how remote images works but we'll need a different approach here

@akawalsky
Copy link
Contributor Author

Ah I see. Would reverting to using regex be acceptable in this case then?

Copy link
Contributor

@gnoff gnoff left a comment

Choose a reason for hiding this comment

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

You don't have to take me exact suggestion but I think we should go with an approach that is more memory efficient and strict about the wildcard matching rules

Comment on lines 10 to 33
// Iterate through each part and compare them
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i] === '**') {
// If '**' is encountered, ensure remaining domain ends with remaining pattern
// e.g. **.y.z and a.b.c.y.z should match (b.c.y.z ends with y.z)
const remainingPattern = patternParts.slice(i + 1).join('.')
const remainingDomain = domainParts.slice(i + 1).join('.')
return remainingDomain.endsWith(remainingPattern)
} else if (patternParts[i] === '*') {
// If '*' is encountered, ensure remaining domain is equal to remaining pattern
// e.g. *.y.z and c.y.z should match (y.z is equal to y.z)
const remainingPattern = patternParts.slice(i + 1).join('.')
const remainingDomain = domainParts.slice(i + 1).join('.')
return remainingDomain === remainingPattern
}

// If '*' is not encountered, compare the parts
if (patternParts[i] !== domainParts[i]) {
return false
}
}

return true
}
Copy link
Contributor

@gnoff gnoff Dec 12, 2023

Choose a reason for hiding this comment

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

Suggested change
// Iterate through each part and compare them
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i] === '**') {
// If '**' is encountered, ensure remaining domain ends with remaining pattern
// e.g. **.y.z and a.b.c.y.z should match (b.c.y.z ends with y.z)
const remainingPattern = patternParts.slice(i + 1).join('.')
const remainingDomain = domainParts.slice(i + 1).join('.')
return remainingDomain.endsWith(remainingPattern)
} else if (patternParts[i] === '*') {
// If '*' is encountered, ensure remaining domain is equal to remaining pattern
// e.g. *.y.z and c.y.z should match (y.z is equal to y.z)
const remainingPattern = patternParts.slice(i + 1).join('.')
const remainingDomain = domainParts.slice(i + 1).join('.')
return remainingDomain === remainingPattern
}
// If '*' is not encountered, compare the parts
if (patternParts[i] !== domainParts[i]) {
return false
}
}
return true
}
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 pattern = patternParts.pop();
const domain = domainParts.pop();
switch (pattern) {
case "":
case "*":
case "**" {
// invalid pattern. pattern segments must be non empty
// Additionally wildcards are only supported below the domain level
return false
}
default: {
if (domain !== pattern) {
return false;
}
}
}
while (patternParts.length) {
const pattern = patternParts.pop();
const domain = domainParts.pop();
switch (pattern) {
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 (domain.length) {
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 domain.length > 0
}
default: {
if (domain !== pattern) {
return false;
}
}
}
// We exhausted the pattern. If we also exhausted the domain we have a match
return domainParts.length === 0
}

This implementation is a much lower overhead on memory allocations and iterations
It also is more strict, only allowing ** at the front and only supporting wildcards at the subdomain and deeper level

Copy link
Contributor Author

Choose a reason for hiding this comment

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

works for me, got it running and replaced with your implementation

@ijjk
Copy link
Member

ijjk commented Dec 12, 2023

Stats from current PR

Default Build (Increase detected ⚠️)
General Overall increase ⚠️
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
buildDuration 10.9s 10.7s N/A
buildDurationCached 6.1s 6.1s N/A
nodeModulesSize 200 MB 200 MB ⚠️ +74 kB
nextStartRea..uration (ms) 422ms 422ms
Client Bundles (main, webpack)
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
170-HASH.js gzip 26.7 kB 26.7 kB N/A
199.HASH.js gzip 181 B 185 B N/A
3f784ff6-HASH.js gzip 53.3 kB 53.3 kB
framework-HASH.js gzip 45.2 kB 45.2 kB
main-app-HASH.js gzip 240 B 241 B N/A
main-HASH.js gzip 31.7 kB 31.6 kB N/A
webpack-HASH.js gzip 1.7 kB 1.7 kB N/A
Overall change 98.5 kB 98.5 kB
Legacy Client Bundles (polyfills)
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
polyfills-HASH.js gzip 31 kB 31 kB
Overall change 31 kB 31 kB
Client Pages
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
_app-HASH.js gzip 195 B 194 B N/A
_error-HASH.js gzip 183 B 182 B N/A
amp-HASH.js gzip 501 B 501 B
css-HASH.js gzip 321 B 321 B
dynamic-HASH.js gzip 2.5 kB 2.5 kB N/A
edge-ssr-HASH.js gzip 255 B 255 B
head-HASH.js gzip 349 B 350 B N/A
hooks-HASH.js gzip 368 B 369 B N/A
image-HASH.js gzip 4.27 kB 4.27 kB N/A
index-HASH.js gzip 255 B 256 B N/A
link-HASH.js gzip 2.61 kB 2.6 kB N/A
routerDirect..HASH.js gzip 311 B 309 B N/A
script-HASH.js gzip 384 B 384 B
withRouter-HASH.js gzip 307 B 306 B N/A
1afbb74e6ecf..834.css gzip 106 B 106 B
Overall change 1.57 kB 1.57 kB
Client Build Manifests
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
_buildManifest.js gzip 484 B 482 B N/A
Overall change 0 B 0 B
Rendered Page Sizes
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
index.html gzip 530 B 527 B N/A
link.html gzip 542 B 540 B N/A
withRouter.html gzip 524 B 524 B
Overall change 524 B 524 B
Edge SSR bundle Size Overall increase ⚠️
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
edge-ssr.js gzip 93.7 kB 93.7 kB N/A
page.js gzip 146 kB 146 kB ⚠️ +158 B
Overall change 146 kB 146 kB ⚠️ +158 B
Middleware size
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
middleware-b..fest.js gzip 627 B 624 B N/A
middleware-r..fest.js gzip 151 B 151 B
middleware.js gzip 37.4 kB 37.4 kB N/A
edge-runtime..pack.js gzip 1.92 kB 1.92 kB
Overall change 2.07 kB 2.07 kB
Next Runtimes Overall increase ⚠️
vercel/next.js canary akawalsky/next.js server-actions-regex-allowed-domains Change
app-page-exp...dev.js gzip 168 kB 168 kB ⚠️ +147 B
app-page-exp..prod.js gzip 93.9 kB 94.1 kB ⚠️ +144 B
app-page-tur..prod.js gzip 94.7 kB 94.8 kB ⚠️ +144 B
app-page-tur..prod.js gzip 89.2 kB 89.4 kB ⚠️ +146 B
app-page.run...dev.js gzip 138 kB 138 kB ⚠️ +152 B
app-page.run..prod.js gzip 88.5 kB 88.7 kB ⚠️ +145 B
app-route-ex...dev.js gzip 23.9 kB 23.9 kB
app-route-ex..prod.js gzip 16.6 kB 16.6 kB
app-route-tu..prod.js gzip 16.6 kB 16.6 kB
app-route-tu..prod.js gzip 16.2 kB 16.2 kB
app-route.ru...dev.js gzip 23.4 kB 23.4 kB
app-route.ru..prod.js gzip 16.2 kB 16.2 kB
pages-api-tu..prod.js gzip 9.37 kB 9.37 kB
pages-api.ru...dev.js gzip 9.64 kB 9.64 kB
pages-api.ru..prod.js gzip 9.37 kB 9.37 kB
pages-turbo...prod.js gzip 21.9 kB 21.9 kB
pages.runtim...dev.js gzip 22.5 kB 22.5 kB
pages.runtim..prod.js gzip 21.9 kB 21.9 kB
server.runti..prod.js gzip 49.4 kB 49.4 kB
Overall change 929 kB 930 kB ⚠️ +878 B
Diff details
Diff for page.js

Diff too large to display

Diff for app-page-exp..ntime.dev.js

Diff too large to display

Diff for app-page-exp..time.prod.js

Diff too large to display

Diff for app-page-tur..time.prod.js

Diff too large to display

Diff for app-page-tur..time.prod.js

Diff too large to display

Diff for app-page.runtime.dev.js

Diff too large to display

Diff for app-page.runtime.prod.js

Diff too large to display

Commit: c330fb7

@akawalsky akawalsky requested a review from gnoff December 12, 2023 15:24
@gnoff gnoff merged commit 6fbff29 into vercel:canary Dec 12, 2023
68 checks passed
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 27, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants