diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 4db5e3179589a83..579cfb8a230da3f 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -1,4 +1,4 @@ -import { webpack, sources } from 'next/dist/compiled/webpack/webpack' +import { webpack, sources, webpack5 } from 'next/dist/compiled/webpack/webpack' import { getMiddlewareRegex } from '../../../shared/lib/router/utils' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { @@ -39,7 +39,7 @@ export default class MiddlewarePlugin { } createAssets( - compilation: any, + compilation: webpack5.Compilation, assets: any, envPerRoute: Map ) { @@ -52,6 +52,7 @@ export default class MiddlewarePlugin { } for (const entrypoint of entrypoints.values()) { + if (!entrypoint.name) continue const result = MIDDLEWARE_FULL_ROUTE_REGEX.exec(entrypoint.name) const ssrEntryInfo = ssrEntries.get(entrypoint.name) @@ -111,19 +112,21 @@ export default class MiddlewarePlugin { ) } - apply(compiler: webpack.Compiler) { + apply(compiler: webpack5.Compiler) { + const { dev } = this + const wp = compiler.webpack compiler.hooks.compilation.tap( PLUGIN_NAME, (compilation, { normalModuleFactory }) => { const envPerRoute = new Map() - compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + compilation.hooks.afterOptimizeModules.tap(PLUGIN_NAME, () => { const { moduleGraph } = compilation as any envPerRoute.clear() for (const [name, info] of compilation.entries) { if (name.match(MIDDLEWARE_ROUTE)) { - const middlewareEntries = new Set() + const middlewareEntries = new Set() const env = new Set() const addEntriesFromDependency = (dep: any) => { @@ -133,19 +136,41 @@ export default class MiddlewarePlugin { } } + const runtime = wp.util.runtime.getEntryRuntime(compilation, name) + info.dependencies.forEach(addEntriesFromDependency) info.includeDependencies.forEach(addEntriesFromDependency) const queue = new Set(middlewareEntries) for (const module of queue) { - const { buildInfo } = module as any - if (buildInfo?.usingIndirectEval) { - // @ts-ignore TODO: Remove ignore when webpack 5 is stable - const error = new webpack.WebpackError( - `\`eval\` not allowed in Middleware ${name}` + const { buildInfo } = module + if ( + !dev && + buildInfo && + isUsedByExports({ + module, + moduleGraph, + runtime, + usedByExports: buildInfo.usingIndirectEval, + }) + ) { + if ( + /node_modules[\\/]regenerator-runtime[\\/]runtime\.js/.test( + module.identifier() + ) + ) + continue + const error = new wp.WebpackError( + `Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware ${name}${ + typeof buildInfo.usingIndirectEval !== 'boolean' + ? `\nUsed by ${Array.from( + buildInfo.usingIndirectEval + ).join(', ')}` + : '' + }` ) error.module = module - compilation.warnings.push(error) + compilation.errors.push(error) } if (buildInfo?.nextUsedEnvVars !== undefined) { @@ -167,19 +192,82 @@ export default class MiddlewarePlugin { } }) - const handler = (parser: any) => { - const flagModule = () => { - parser.state.module.buildInfo.usingIndirectEval = true + const handler = (parser: webpack5.javascript.JavascriptParser) => { + const wrapExpression = (expr: any) => { + if (dev) { + const dep1 = new wp.dependencies.ConstDependency( + '__next_eval__(function() { return ', + expr.range[0] + ) + dep1.loc = expr.loc + parser.state.module.addPresentationalDependency(dep1) + const dep2 = new wp.dependencies.ConstDependency( + '})', + expr.range[1] + ) + dep2.loc = expr.loc + parser.state.module.addPresentationalDependency(dep2) + } + expressionHandler() + return true + } + + const flagModule = ( + usedByExports: boolean | Set | undefined + ) => { + if (usedByExports === undefined) usedByExports = true + const old = parser.state.module.buildInfo.usingIndirectEval + if (old === true || usedByExports === false) return + if (!old || usedByExports === true) { + parser.state.module.buildInfo.usingIndirectEval = usedByExports + return + } + const set = new Set(old) + for (const item of usedByExports) { + set.add(item) + } + parser.state.module.buildInfo.usingIndirectEval = set + } + + const expressionHandler = () => { + wp.optimize.InnerGraph.onUsage(parser.state, flagModule) + } + + const ignore = () => { + return true } - parser.hooks.expression.for('eval').tap(PLUGIN_NAME, flagModule) - parser.hooks.expression.for('Function').tap(PLUGIN_NAME, flagModule) + // wrapping + parser.hooks.call.for('eval').tap(PLUGIN_NAME, wrapExpression) + parser.hooks.call.for('global.eval').tap(PLUGIN_NAME, wrapExpression) + parser.hooks.call.for('Function').tap(PLUGIN_NAME, wrapExpression) + parser.hooks.call + .for('global.Function') + .tap(PLUGIN_NAME, wrapExpression) + parser.hooks.new.for('Function').tap(PLUGIN_NAME, wrapExpression) + parser.hooks.new + .for('global.Function') + .tap(PLUGIN_NAME, wrapExpression) + + // fallbacks + parser.hooks.expression + .for('eval') + .tap(PLUGIN_NAME, expressionHandler) + parser.hooks.expression + .for('Function') + .tap(PLUGIN_NAME, expressionHandler) + parser.hooks.expression + .for('Function.prototype') + .tap(PLUGIN_NAME, ignore) parser.hooks.expression .for('global.eval') - .tap(PLUGIN_NAME, flagModule) + .tap(PLUGIN_NAME, expressionHandler) parser.hooks.expression .for('global.Function') - .tap(PLUGIN_NAME, flagModule) + .tap(PLUGIN_NAME, expressionHandler) + parser.hooks.expression + .for('global.Function.prototype') + .tap(PLUGIN_NAME, ignore) const memberChainHandler = (_expr: any, members: string[]) => { if ( @@ -237,3 +325,21 @@ export default class MiddlewarePlugin { ) } } + +function isUsedByExports(args: { + module: webpack5.Module + moduleGraph: webpack5.ModuleGraph + runtime: any + usedByExports: boolean | Set | undefined +}): boolean { + const { moduleGraph, runtime, module, usedByExports } = args + if (usedByExports === undefined) return false + if (typeof usedByExports === 'boolean') return usedByExports + const exportsInfo = moduleGraph.getExportsInfo(module) + const wp = webpack as unknown as typeof webpack5 + for (const exportName of usedByExports) { + if (exportsInfo.getUsed(exportName, runtime) !== wp.UsageState.Unused) + return true + } + return false +} diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 731398f9f359361..03ac4dec26d562e 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -514,7 +514,13 @@ export default class DevServer extends Server { parsed: UrlWithParsedQuery }): Promise { try { - const result = await super.runMiddleware(params) + const result = await super.runMiddleware({ + ...params, + onWarning: (warn) => { + this.logErrorWithOriginalStack(warn, 'warning', 'client') + }, + }) + result?.waitUntil.catch((error) => this.logErrorWithOriginalStack(error, 'unhandledRejection', 'client') ) @@ -589,7 +595,7 @@ export default class DevServer extends Server { private async logErrorWithOriginalStack( err?: unknown, - type?: 'unhandledRejection' | 'uncaughtException', + type?: 'unhandledRejection' | 'uncaughtException' | 'warning', stats: 'server' | 'client' = 'server' ) { let usedOriginalStack = false @@ -630,11 +636,15 @@ export default class DevServer extends Server { const { file, lineNumber, column, methodName } = originalStackFrame console.error( - chalk.red('error') + + (type === 'warning' ? chalk.yellow('warn') : chalk.red('error')) + ' - ' + `${file} (${lineNumber}:${column}) @ ${methodName}` ) - console.error(`${chalk.red(err.name)}: ${err.message}`) + console.error( + `${(type === 'warning' ? chalk.yellow : chalk.red)(err.name)}: ${ + err.message + }` + ) console.error(originalCodeFrame) usedOriginalStack = true } @@ -647,7 +657,9 @@ export default class DevServer extends Server { } if (!usedOriginalStack) { - if (type) { + if (type === 'warning') { + Log.warn(err + '') + } else if (type) { Log.error(`${type}:`, err + '') } else { Log.error(err + '') diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index b4ce74c36472fcb..e6cd4f8f579f900 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -611,6 +611,7 @@ export default class Server { response: ServerResponse parsedUrl: ParsedNextUrl parsed: UrlWithParsedQuery + onWarning?: (warning: Error) => void }): Promise { this.middlewareBetaWarning() @@ -672,6 +673,12 @@ export default class Server { page: page, }, ssr: !!this.nextConfig.experimental.concurrentFeatures, + onWarning: (warning: Error) => { + if (params.onWarning) { + warning.message += ` "./${middlewareInfo.name}"` + params.onWarning(warning) + } + }, }) for (let [key, value] of result.response.headers) { diff --git a/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index a6e3688f236c46c..94afaf0b501fd41 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -10,9 +10,11 @@ import vm from 'vm' let cache: | { context: { [key: string]: any } + onWarning: (warn: Error) => void paths: Map require: Map sandbox: vm.Context + warnedEvals: Set } | undefined @@ -33,12 +35,14 @@ export function clearSandboxCache(path: string, content: Buffer | string) { export async function run(params: { name: string + onWarning: (warn: Error) => void paths: string[] request: RequestData ssr: boolean }): Promise { if (cache === undefined) { const context: { [key: string]: any } = { + __next_eval__, _ENTRIES: {}, atob: polyfills.atob, Blob, @@ -89,11 +93,21 @@ export async function run(params: { cache = { context, + onWarning: params.onWarning, + paths: new Map(), require: new Map([ [require.resolve('next/dist/compiled/cookie'), { exports: cookie }], ]), - paths: new Map(), - sandbox: vm.createContext(context), + sandbox: vm.createContext(context, { + codeGeneration: + process.env.NODE_ENV === 'production' + ? { + strings: false, + wasm: false, + } + : undefined, + }), + warnedEvals: new Set(), } loadDependencies(cache.sandbox, [ @@ -110,6 +124,8 @@ export async function run(params: { map: { Request: 'Request' }, }, ]) + } else { + cache.onWarning = params.onWarning } for (const paramPath of params.paths) { @@ -218,3 +234,17 @@ function getFetchURL(input: RequestInfo, headers: NodeHeaders = {}): string { function isRequestLike(obj: unknown): obj is Request { return Boolean(obj && typeof obj === 'object' && 'url' in obj) } + +function __next_eval__(fn: Function) { + const key = fn.toString() + if (!cache?.warnedEvals.has(key)) { + const warning = new Error( + `Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware` + ) + warning.name = 'DynamicCodeEvaluationWarning' + Error.captureStackTrace(warning, __next_eval__) + cache?.warnedEvals.add(key) + cache?.onWarning(warning) + } + return fn() +} diff --git a/test/integration/middleware/core/lib/utils.js b/test/integration/middleware/core/lib/utils.js new file mode 100644 index 000000000000000..1e3f457afc11ac3 --- /dev/null +++ b/test/integration/middleware/core/lib/utils.js @@ -0,0 +1,8 @@ +export function getTextWithEval() { + // eslint-disable-next-line no-eval + return eval('with some text') +} + +export function getText() { + return 'with some text' +} diff --git a/test/integration/middleware-core/next.config.js b/test/integration/middleware/core/next.config.js similarity index 100% rename from test/integration/middleware-core/next.config.js rename to test/integration/middleware/core/next.config.js diff --git a/test/integration/middleware-core/pages/interface/[id]/index.js b/test/integration/middleware/core/pages/interface/[id]/index.js similarity index 100% rename from test/integration/middleware-core/pages/interface/[id]/index.js rename to test/integration/middleware/core/pages/interface/[id]/index.js diff --git a/test/integration/middleware-core/pages/interface/_middleware.js b/test/integration/middleware/core/pages/interface/_middleware.js similarity index 100% rename from test/integration/middleware-core/pages/interface/_middleware.js rename to test/integration/middleware/core/pages/interface/_middleware.js diff --git a/test/integration/middleware-core/pages/interface/static.js b/test/integration/middleware/core/pages/interface/static.js similarity index 100% rename from test/integration/middleware-core/pages/interface/static.js rename to test/integration/middleware/core/pages/interface/static.js diff --git a/test/integration/middleware-core/pages/redirects/_middleware.js b/test/integration/middleware/core/pages/redirects/_middleware.js similarity index 100% rename from test/integration/middleware-core/pages/redirects/_middleware.js rename to test/integration/middleware/core/pages/redirects/_middleware.js diff --git a/test/integration/middleware-core/pages/redirects/header.js b/test/integration/middleware/core/pages/redirects/header.js similarity index 100% rename from test/integration/middleware-core/pages/redirects/header.js rename to test/integration/middleware/core/pages/redirects/header.js diff --git a/test/integration/middleware-core/pages/redirects/index.js b/test/integration/middleware/core/pages/redirects/index.js similarity index 100% rename from test/integration/middleware-core/pages/redirects/index.js rename to test/integration/middleware/core/pages/redirects/index.js diff --git a/test/integration/middleware-core/pages/redirects/new-home.js b/test/integration/middleware/core/pages/redirects/new-home.js similarity index 100% rename from test/integration/middleware-core/pages/redirects/new-home.js rename to test/integration/middleware/core/pages/redirects/new-home.js diff --git a/test/integration/middleware-core/pages/redirects/old-home.js b/test/integration/middleware/core/pages/redirects/old-home.js similarity index 100% rename from test/integration/middleware-core/pages/redirects/old-home.js rename to test/integration/middleware/core/pages/redirects/old-home.js diff --git a/test/integration/middleware-core/pages/responses/_middleware.js b/test/integration/middleware/core/pages/responses/_middleware.js similarity index 95% rename from test/integration/middleware-core/pages/responses/_middleware.js rename to test/integration/middleware/core/pages/responses/_middleware.js index 7a6f66ee09bdb5f..255f1c3180a1b84 100644 --- a/test/integration/middleware-core/pages/responses/_middleware.js +++ b/test/integration/middleware/core/pages/responses/_middleware.js @@ -1,6 +1,7 @@ import { createElement } from 'react' import { renderToString } from 'react-dom/server.browser' import { NextResponse } from 'next/server' +import { getText } from '../../lib/utils' export async function middleware(request, ev) { // eslint-disable-next-line no-undef @@ -36,7 +37,8 @@ export async function middleware(request, ev) { ev.waitUntil( (async () => { writer.write(encoder.encode('this is a streamed ')) - writer.write(encoder.encode('response')) + writer.write(encoder.encode('response ')) + writer.write(encoder.encode(getText())) writer.close() })() ) diff --git a/test/integration/middleware-core/pages/responses/deep/_middleware.js b/test/integration/middleware/core/pages/responses/deep/_middleware.js similarity index 100% rename from test/integration/middleware-core/pages/responses/deep/_middleware.js rename to test/integration/middleware/core/pages/responses/deep/_middleware.js diff --git a/test/integration/middleware-core/pages/responses/deep/index.js b/test/integration/middleware/core/pages/responses/deep/index.js similarity index 100% rename from test/integration/middleware-core/pages/responses/deep/index.js rename to test/integration/middleware/core/pages/responses/deep/index.js diff --git a/test/integration/middleware-core/pages/responses/index.js b/test/integration/middleware/core/pages/responses/index.js similarity index 100% rename from test/integration/middleware-core/pages/responses/index.js rename to test/integration/middleware/core/pages/responses/index.js diff --git a/test/integration/middleware-core/pages/rewrites/_middleware.js b/test/integration/middleware/core/pages/rewrites/_middleware.js similarity index 100% rename from test/integration/middleware-core/pages/rewrites/_middleware.js rename to test/integration/middleware/core/pages/rewrites/_middleware.js diff --git a/test/integration/middleware-core/pages/rewrites/a.js b/test/integration/middleware/core/pages/rewrites/a.js similarity index 100% rename from test/integration/middleware-core/pages/rewrites/a.js rename to test/integration/middleware/core/pages/rewrites/a.js diff --git a/test/integration/middleware-core/pages/rewrites/about.js b/test/integration/middleware/core/pages/rewrites/about.js similarity index 100% rename from test/integration/middleware-core/pages/rewrites/about.js rename to test/integration/middleware/core/pages/rewrites/about.js diff --git a/test/integration/middleware-core/pages/rewrites/b.js b/test/integration/middleware/core/pages/rewrites/b.js similarity index 100% rename from test/integration/middleware-core/pages/rewrites/b.js rename to test/integration/middleware/core/pages/rewrites/b.js diff --git a/test/integration/middleware-core/pages/rewrites/index.js b/test/integration/middleware/core/pages/rewrites/index.js similarity index 100% rename from test/integration/middleware-core/pages/rewrites/index.js rename to test/integration/middleware/core/pages/rewrites/index.js diff --git a/test/integration/middleware-core/test/index.test.js b/test/integration/middleware/core/test/index.test.js similarity index 99% rename from test/integration/middleware-core/test/index.test.js rename to test/integration/middleware/core/test/index.test.js index 66d1cc11fd6fec9..2c386dfe9970996 100644 --- a/test/integration/middleware-core/test/index.test.js +++ b/test/integration/middleware/core/test/index.test.js @@ -254,7 +254,7 @@ function responseTests(locale = '') { `${locale}/responses/stream-a-response` ) const html = await res.text() - expect(html).toBe('this is a streamed response') + expect(html).toBe('this is a streamed response with some text') }) it(`${locale} should respond with a body`, async () => { diff --git a/test/integration/middleware-base-path/next.config.js b/test/integration/middleware/with-base-path/next.config.js similarity index 100% rename from test/integration/middleware-base-path/next.config.js rename to test/integration/middleware/with-base-path/next.config.js diff --git a/test/integration/middleware-base-path/pages/_middleware.js b/test/integration/middleware/with-base-path/pages/_middleware.js similarity index 100% rename from test/integration/middleware-base-path/pages/_middleware.js rename to test/integration/middleware/with-base-path/pages/_middleware.js diff --git a/test/integration/middleware-base-path/pages/about.js b/test/integration/middleware/with-base-path/pages/about.js similarity index 100% rename from test/integration/middleware-base-path/pages/about.js rename to test/integration/middleware/with-base-path/pages/about.js diff --git a/test/integration/middleware-base-path/pages/index.js b/test/integration/middleware/with-base-path/pages/index.js similarity index 100% rename from test/integration/middleware-base-path/pages/index.js rename to test/integration/middleware/with-base-path/pages/index.js diff --git a/test/integration/middleware-base-path/test/index.test.js b/test/integration/middleware/with-base-path/test/index.test.js similarity index 100% rename from test/integration/middleware-base-path/test/index.test.js rename to test/integration/middleware/with-base-path/test/index.test.js diff --git a/test/integration/middleware/with-eval/lib/utils.js b/test/integration/middleware/with-eval/lib/utils.js new file mode 100644 index 000000000000000..3c17949f4c9f05f --- /dev/null +++ b/test/integration/middleware/with-eval/lib/utils.js @@ -0,0 +1,8 @@ +export async function usingEval() { + // eslint-disable-next-line no-eval + return { value: eval('100') } +} + +export async function notUsingEval() { + return { value: 100 } +} diff --git a/test/integration/middleware/with-eval/pages/_middleware.js b/test/integration/middleware/with-eval/pages/_middleware.js new file mode 100644 index 000000000000000..0ea96d0bb80239a --- /dev/null +++ b/test/integration/middleware/with-eval/pages/_middleware.js @@ -0,0 +1,19 @@ +import { notUsingEval, usingEval } from '../lib/utils' + +export async function middleware(request) { + if (request.nextUrl.pathname === '/using-eval') { + return new Response(JSON.stringify(await usingEval()), { + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + if (request.nextUrl.pathname === '/not-using-eval') { + return new Response(JSON.stringify(await notUsingEval()), { + headers: { + 'Content-Type': 'application/json', + }, + }) + } +} diff --git a/test/integration/middleware/with-eval/test/index.test.js b/test/integration/middleware/with-eval/test/index.test.js new file mode 100644 index 000000000000000..acd86cf95292078 --- /dev/null +++ b/test/integration/middleware/with-eval/test/index.test.js @@ -0,0 +1,79 @@ +/* eslint-env jest */ + +import stripAnsi from 'next/dist/compiled/strip-ansi' +import { join } from 'path' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + waitFor, +} from 'next-test-utils' + +const context = {} +const DYNAMIC_CODE_ERROR = `Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware` + +jest.setTimeout(1000 * 60 * 2) +context.appDir = join(__dirname, '../') + +describe('Middleware usage of dynamic code evaluation', () => { + describe('dev mode', () => { + let output = '' + + beforeAll(async () => { + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort, { + env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, + onStdout(msg) { + output += msg + }, + onStderr(msg) { + output += msg + }, + }) + }) + + beforeEach(() => (output = '')) + afterAll(() => killApp(context.app)) + + it('shows a warning when running code with eval', async () => { + const res = await fetchViaHTTP(context.appPort, `/using-eval`) + const json = await res.json() + await waitFor(500) + expect(json.value).toEqual(100) + expect(output).toContain(DYNAMIC_CODE_ERROR) + expect(output).toContain('DynamicCodeEvaluationWarning') + expect(output).toContain('pages/_middleware') + expect(output).toContain('lib/utils.js') + expect(output).toContain('usingEval') + expect(stripAnsi(output)).toContain("value: eval('100')") + }) + + it('does not show warning when no code uses eval', async () => { + const res = await fetchViaHTTP(context.appPort, `/not-using-eval`) + const json = await res.json() + await waitFor(500) + expect(json.value).toEqual(100) + expect(output).not.toContain(DYNAMIC_CODE_ERROR) + }) + }) + + describe('production mode', () => { + let buildResult + + beforeAll(async () => { + buildResult = await nextBuild(context.appDir, undefined, { + stderr: true, + stdout: true, + }) + }) + + it('should have middleware warning during build', () => { + expect(buildResult.stderr).toContain(`Failed to compile`) + expect(buildResult.stderr).toContain(`Used by usingEval`) + expect(buildResult.stderr).toContain(`./pages/_middleware.js`) + expect(buildResult.stderr).toContain(DYNAMIC_CODE_ERROR) + }) + }) +})