vercel/next.js · #98997

Add some more verification and safety rules around custom `distDir`

lukesandberg · merged Sep 23, 20268 files · 201 + / 1 −
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",