vercel/next.js · #98997
Add some more verification and safety rules around custom `distDir`
docs/01-app/03-api-reference/05-config/01-next-config-js/distDir.mdx10 + / 1 −
@@ -17,4 +17,13 @@ module.exports = { Now if you run `next build` Next.js will use `build` instead of the default `.next` folder. -> `distDir` **should not** leave your project directory. For example, `../build` is an **invalid** directory.+> **Good To Know:** Next.js takes ownership of `distDir` and will sometimes delete its contents. Point it at a directory used only by Next.js.++`distDir` is resolved relative to the directory holding your `next.config.js`, and may+point to a dedicated directory inside the workspace that contains it. In a monorepo, an+application at `apps/web` can build into `../.next`.++To protect against data loss, Next.js refuses to use the directory and fails with an+[Invalid distDir](/docs/messages/invalid-dist-dir) error when `distDir` resolves outside+the application and workspace, or when it is the application directory itself or one of+its ancestors.errors/invalid-dist-dir.mdxadded31 + / 0 −
@@ -0,0 +1,31 @@+---+title: Invalid distDir+---++## Why This Error Occurred++Next.js takes full ownership of the directory configured as `distDir` (`.next` by+default). Builds and development sessions write output there and normally delete stale+contents. To protect against data loss, Next.js refuses to use the directory when it:++- resolves outside both the application directory (the one holding `next.config.js`) and+ the workspace root containing it; or+- is the application directory itself or one of its ancestors.++Pointing `distDir` elsewhere _inside_ the workspace is supported when the output does not+contain the application. For example, an application at `apps/web` may use+`distDir: '../.next'`.++## Possible Ways to Fix It++Point `distDir` at a dedicated directory inside the application or workspace:++```js filename="next.config.js"+module.exports = {+ distDir: 'build',+}+```++## Useful Links++- [`distDir` configuration](/docs/app/api-reference/config/next-config-js/distDir)packages/next/src/lib/dist-dir.test.tsadded41 + / 0 −
@@ -0,0 +1,41 @@+import { resolve } from 'path'+import { InvalidDistDirError, verifyDistDir } from './dist-dir'++describe('verifyDistDir', () => {+ // An app at <repo>/apps/web, inside a monorepo rooted at <repo>.+ const appDir = '/repo/apps/web'+ const workspaceRoot = '/repo'+ const verify = (distDir: string) =>+ verifyDistDir(resolve(appDir, distDir), appDir, workspaceRoot)++ it.each([+ '.next', // inside the app+ '.next/dev',+ '..next', // name starts with two dots, but is still inside the app+ '.cache',+ '../.next', // beside the app, still in the workspace+ '../../.next', // at the workspace root, as Nx monorepos use+ '../../..cache', // name starts with two dots, but is inside the workspace+ ])('allows %s', (distDir) => {+ expect(() => verify(distDir)).not.toThrow()+ })++ it.each([+ '.', // the app itself+ '..', // an intermediate directory containing the app+ '../..', // the workspace root, also containing the app+ '../../..', // above the workspace+ '/tmp/elsewhere',+ ])('rejects %s', (distDir) => {+ expect(() => verify(distDir)).toThrow(InvalidDistDirError)+ })++ it.each(['.next', '..next'])(+ 'allows an in-app %s distDir when the inferred workspace root is unrelated',+ (distDir) => {+ expect(() =>+ verifyDistDir(`${appDir}/${distDir}`, appDir, '/custom/root')+ ).not.toThrow()+ }+ )+})packages/next/src/lib/dist-dir.tsadded59 + / 0 −
@@ -0,0 +1,59 @@+import * as path from 'node:path'++export class InvalidDistDirError extends Error {+ constructor(distDir: string, appDir: string, workspaceRoot: string) {+ super(+ `The configured distDir should be inside of the application directory ` ++ `or the workspace containing it, and must not contain the application ` ++ `directory itself:\n\n` ++ ` distDir: ${distDir}\n` ++ ` application: ${appDir}\n` ++ ` workspace root: ${workspaceRoot}\n\n` ++ `Read more: https://nextjs.org/docs/messages/invalid-dist-dir`+ )+ this.name = 'InvalidDistDirError'+ }+}++/** Whether `descendant` is strictly inside `ancestor`. */+// TODO: Account for symlinks when validating containment.+function isStrictlyInside(ancestor: string, descendant: string): boolean {+ const relative = path.relative(path.resolve(ancestor), descendant)+ return (+ relative !== '' &&+ relative !== '..' &&+ !relative.startsWith('..' + path.sep) &&+ !path.isAbsolute(relative)+ )+}++/**+ * Throws unless `distDir` is inside the application or workspace, without+ * containing the application itself.+ */+export function verifyDistDir(+ distDir: string,+ appDir: string,+ workspaceRoot: string+): void {+ const resolvedDistDir = path.resolve(distDir)+ const resolvedAppDir = path.resolve(appDir)+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot)++ const isInsideBoundary =+ isStrictlyInside(resolvedAppDir, resolvedDistDir) ||+ isStrictlyInside(resolvedWorkspaceRoot, resolvedDistDir)+ const containsApp =+ resolvedDistDir === resolvedAppDir ||+ isStrictlyInside(resolvedDistDir, resolvedAppDir)++ if (isInsideBoundary && !containsApp) {+ return+ }++ throw new InvalidDistDirError(+ resolvedDistDir,+ resolvedAppDir,+ resolvedWorkspaceRoot+ )+}packages/next/src/server/config.ts3 + / 0 −
@@ -62,6 +62,7 @@ import type { MemoryEvictionMode, TurbopackGcOptions } from '../build/swc/types' import { hrtimeBigIntDurationToString } from '../build/duration-to-string' export { normalizeConfig } from './config-shared'+import { verifyDistDir } from '../lib/dist-dir' export type { DomainLocale, NextConfig } from './config-shared' const REACT_18_DEPRECATION_WARNING =@@ -1265,6 +1266,8 @@ function assignDefaultsAndValidate( result.outputFileTracingRoot = rootDir dset(result, ['turbopack', 'root'], rootDir) + verifyDistDir(resolve(dir, result.distDir), dir, repoRoot)+ setHttpClientAndAgentOptions(result || defaultConfig) if (result.i18n) {test/e2e/invalid-distdir/invalid-distdir.test.tsadded53 + / 0 −
@@ -0,0 +1,53 @@+/* eslint-env jest */++import { nextTestSetup } from 'e2e-utils'+import { retry } from 'next-test-utils'++// @force-gate !deploy+describe('invalid distDir', () => {+ const { next, skipped, isNextDev } = nextTestSetup({+ files: __dirname,+ skipStart: true,+ })++ if (skipped) return++ const expectStartError = async (expected: string | RegExp) => {+ if (isNextDev) {+ try {+ await next.start()+ await next.fetch('/').catch(() => {})+ } catch {}+ await retry(() => expect(next.cliOutput).toMatch(expected))+ } else {+ await expect(next.start()).rejects.toThrow()+ expect(next.cliOutput).toMatch(expected)+ }+ }++ beforeEach(async () => {+ await next.stop()+ await next.remove('.next')+ })++ it('refuses a distDir outside the application and workspace', async () => {+ await next.patchFile(+ 'next.config.js',+ `module.exports = { distDir: '../..', outputFileTracingRoot: '/' }`,+ async () => {+ await expectStartError('should be inside of the application directory')+ }+ )+ })++ it('refuses a distDir that is the application directory itself', async () => {+ await next.patchFile(+ 'next.config.js',+ `module.exports = { distDir: '.' }`,+ async () => {+ await expectStartError('must not contain the application directory')+ expect(await next.hasFile('pages/index.tsx')).toBe(true)+ }+ )+ })+})test/e2e/invalid-distdir/pages/index.tsxadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p>hello world</p>+}test/production/next-server-nft/next-server-nft.test.ts1 + / 0 −
@@ -247,6 +247,7 @@ async function readNormalizedNFT(next, name) { "/node_modules/next/dist/lib/create-client-router-filter.js", "/node_modules/next/dist/lib/default-transpiled-packages.json", "/node_modules/next/dist/lib/detect-typo.js",+ "/node_modules/next/dist/lib/dist-dir.js", "/node_modules/next/dist/lib/download-swc.js", "/node_modules/next/dist/lib/fallback.js", "/node_modules/next/dist/lib/file-exists.js",