diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d3171ee3b..bc38b8527c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ - Fixed issue where MCP server didn't detect if iOS app uses Crashlytics in projects that use `project.pbxproj` (#9515) - Add logic to synchronize v2 scheduled function timeout with Cloud Schduler's attempt deadline (#9544) +- Prevent deployments of Next.js apps vulnerable to CVE-2025-66478 (#9572) - Updated Data Connect emulator to v2.17.3: - Fixed Swift codegen: Include FirebaseCore import in the connector keys file. - Fixed a bug where debug details of Internal errors were swallowed: https://github.com/firebase/firebase-tools/issues/9508 diff --git a/src/frameworks/next/index.ts b/src/frameworks/next/index.ts index b381fbba157..2688bc5753a 100644 --- a/src/frameworks/next/index.ts +++ b/src/frameworks/next/index.ts @@ -8,7 +8,7 @@ import type { DomainLocale } from "next/dist/server/config"; import type { PagesManifest } from "next/dist/build/webpack/plugins/pages-manifest-plugin"; import { copy, mkdirp, pathExists, pathExistsSync, readFile } from "fs-extra"; import { pathToFileURL, parse } from "url"; -import { gte } from "semver"; +import { gte, coerce } from "semver"; import { IncomingMessage, ServerResponse } from "http"; import * as clc from "colorette"; import { chain } from "stream-chain"; @@ -58,6 +58,9 @@ import { whichNextConfigFile, installEsbuild, findEsbuildPath, + isUsingAppDirectory, + getNextVersionRaw, + isNextJsVersionVulnerable, } from "./utils"; import { NODE_VERSION, NPM_COMMAND_TIMEOUT_MILLIES, SHARP_VERSION, I18N_ROOT } from "../constants"; import type { @@ -339,6 +342,33 @@ export async function build( const wantsBackend = reasonsForBackend.size > 0; + if (wantsBackend && isUsingAppDirectory(join(dir, distDir))) { + const nextVersion = getNextVersionRaw(dir); + if (nextVersion && isNextJsVersionVulnerable(nextVersion)) { + let message = + `Next.js version ${nextVersion} is vulnerable to CVE-2025-66478.\n` + + `Please upgrade to a patched version: `; + + const { major } = coerce(nextVersion) || {}; + if (major === 16) { + message += "16.0.7+."; + } else if (major === 15) { + message += "15.0.5+, 15.1.9+, 15.2.6+, 15.3.6+, 15.4.8+, or 15.5.7+."; + } else if (major === 14) { + message += "downgrade to a stable Next.js 14.x release."; + } else { + // Fallback for unexpected cases + message += + "15.0.5+, 15.1.9+, 15.2.6+, 15.3.6+, 15.4.8+, 15.5.7+, 16.0.7+ " + + "or downgrade to a stable Next.js 14.x release if using canary."; + } + + message += `\nSee https://nextjs.org/blog/CVE-2025-66478 for more details.`; + + throw new FirebaseError(message); + } + } + if (wantsBackend) { logger.info("Building a Cloud Function to run this application. This is needed due to:"); for (const reason of Array.from(reasonsForBackend).slice( diff --git a/src/frameworks/next/utils.spec.ts b/src/frameworks/next/utils.spec.ts index 65eea3f100e..8eba23c13eb 100644 --- a/src/frameworks/next/utils.spec.ts +++ b/src/frameworks/next/utils.spec.ts @@ -35,9 +35,11 @@ import { getAppMetadataFromMetaFiles, isUsingNextImageInAppDirectory, getNextVersion, + getNextVersionRaw, getRoutesWithServerAction, findEsbuildPath, installEsbuild, + isNextJsVersionVulnerable, } from "./utils"; import * as frameworksUtils from "../utils"; @@ -556,6 +558,30 @@ describe("Next.js utils", () => { }); }); + describe("getNextVersionRaw", () => { + let sandbox: sinon.SinonSandbox; + beforeEach(() => (sandbox = sinon.createSandbox())); + afterEach(() => sandbox.restore()); + + it("should get version", () => { + sandbox.stub(frameworksUtils, "findDependency").returns({ version: "13.4.10" }); + + expect(getNextVersionRaw("")).to.equal("13.4.10"); + }); + + it("should return exact version including canary", () => { + sandbox.stub(frameworksUtils, "findDependency").returns({ version: "13.4.10-canary.0" }); + + expect(getNextVersionRaw("")).to.equal("13.4.10-canary.0"); + }); + + it("should return undefined if unable to get version", () => { + sandbox.stub(frameworksUtils, "findDependency").returns(undefined); + + expect(getNextVersionRaw("")).to.be.undefined; + }); + }); + describe("getRoutesWithServerAction", () => { it("should get routes with server action", () => { expect( @@ -651,4 +677,128 @@ describe("Next.js utils", () => { } }); }); + + describe("isNextJsVersionVulnerable", () => { + describe("vulnerable versions", () => { + it("should block vulnerable 15.0.x versions (< 15.0.5)", () => { + expect(isNextJsVersionVulnerable("15.0.4")).to.be.true; + expect(isNextJsVersionVulnerable("15.0.0")).to.be.true; + expect(isNextJsVersionVulnerable("15.0.0-rc.1")).to.be.true; + expect(isNextJsVersionVulnerable("15.0.0-canary.205")).to.be.true; + }); + + it("should block vulnerable 15.1.x versions (< 15.1.9)", () => { + expect(isNextJsVersionVulnerable("15.1.8")).to.be.true; + expect(isNextJsVersionVulnerable("15.1.0")).to.be.true; + expect(isNextJsVersionVulnerable("15.1.1-canary.27")).to.be.true; + }); + + it("should block vulnerable 15.2.x versions (< 15.2.6)", () => { + expect(isNextJsVersionVulnerable("15.2.5")).to.be.true; + expect(isNextJsVersionVulnerable("15.2.0-canary.77")).to.be.true; + }); + + it("should block vulnerable 15.3.x versions (< 15.3.6)", () => { + expect(isNextJsVersionVulnerable("15.3.5")).to.be.true; + expect(isNextJsVersionVulnerable("15.3.0-canary.46")).to.be.true; + }); + + it("should block vulnerable 15.4.x versions (< 15.4.8)", () => { + expect(isNextJsVersionVulnerable("15.4.7")).to.be.true; + expect(isNextJsVersionVulnerable("15.4.2-canary.56")).to.be.true; + expect(isNextJsVersionVulnerable("15.4.0-canary.130")).to.be.true; + }); + + it("should block vulnerable 15.5.x versions (< 15.5.7)", () => { + expect(isNextJsVersionVulnerable("15.5.6")).to.be.true; + expect(isNextJsVersionVulnerable("15.5.1-canary.39")).to.be.true; + }); + + it("should block vulnerable 16.0.x versions (< 16.0.7)", () => { + expect(isNextJsVersionVulnerable("16.0.6")).to.be.true; + expect(isNextJsVersionVulnerable("16.0.0-beta.0")).to.be.true; + expect(isNextJsVersionVulnerable("16.0.0-canary.18")).to.be.true; + expect(isNextJsVersionVulnerable("16.0.2-canary.34")).to.be.true; + }); + + it("should block vulnerable 14.x canary versions (>= 14.3.0-canary.77)", () => { + expect(isNextJsVersionVulnerable("14.3.0-canary.77")).to.be.true; + expect(isNextJsVersionVulnerable("14.3.0-canary.87")).to.be.true; + }); + + it("should treat pre-releases of patched versions as vulnerable (conservative)", () => { + expect(isNextJsVersionVulnerable("15.0.5-canary.1")).to.be.true; + }); + + it("should block versions with build metadata if base is vulnerable", () => { + expect(isNextJsVersionVulnerable("15.0.4+build123")).to.be.true; + }); + }); + + describe("safe versions", () => { + it("should allow patched 15.0.x versions (>= 15.0.5)", () => { + expect(isNextJsVersionVulnerable("15.0.5")).to.be.false; + expect(isNextJsVersionVulnerable("15.0.6")).to.be.false; + }); + + it("should allow patched 15.1.x versions (>= 15.1.9)", () => { + expect(isNextJsVersionVulnerable("15.1.9")).to.be.false; + }); + + it("should allow patched 15.2.x versions (>= 15.2.6)", () => { + expect(isNextJsVersionVulnerable("15.2.6")).to.be.false; + }); + + it("should allow patched 15.3.x versions (>= 15.3.6)", () => { + expect(isNextJsVersionVulnerable("15.3.6")).to.be.false; + }); + + it("should allow patched 15.4.x versions (>= 15.4.8)", () => { + expect(isNextJsVersionVulnerable("15.4.8")).to.be.false; + }); + + it("should allow patched 15.5.x versions (>= 15.5.7)", () => { + expect(isNextJsVersionVulnerable("15.5.7")).to.be.false; + }); + + it("should allow newer minor versions (e.g. 15.6.x)", () => { + expect(isNextJsVersionVulnerable("15.6.0-canary.57")).to.be.false; + }); + + it("should allow patched 16.0.x versions (>= 16.0.7)", () => { + expect(isNextJsVersionVulnerable("16.0.7")).to.be.false; + }); + + it("should allow newer 16.x minor versions (e.g. 16.1.x)", () => { + expect(isNextJsVersionVulnerable("16.1.0-canary.12")).to.be.false; + }); + + it("should allow safe 14.x canary versions (< 14.3.0-canary.77)", () => { + expect(isNextJsVersionVulnerable("14.3.0-canary.76")).to.be.false; + expect(isNextJsVersionVulnerable("14.3.0-canary.43")).to.be.false; + expect(isNextJsVersionVulnerable("14.2.0-canary.67")).to.be.false; + }); + + it("should allow stable 14.x versions (not vulnerable)", () => { + expect(isNextJsVersionVulnerable("14.3.0")).to.be.false; + expect(isNextJsVersionVulnerable("14.2.33")).to.be.false; + expect(isNextJsVersionVulnerable("14.1.4")).to.be.false; + }); + + it("should allow unaffected older versions", () => { + expect(isNextJsVersionVulnerable("13.5.11")).to.be.false; + expect(isNextJsVersionVulnerable("12.3.7")).to.be.false; + }); + + it("should allow versions with build metadata if base is safe", () => { + expect(isNextJsVersionVulnerable("15.0.5+build123")).to.be.false; + }); + + it("should return false for invalid versions (fail open)", () => { + expect(isNextJsVersionVulnerable("invalid-version")).to.be.false; + expect(isNextJsVersionVulnerable("")).to.be.false; + expect(isNextJsVersionVulnerable(undefined as any)).to.be.false; + }); + }); + }); }); diff --git a/src/frameworks/next/utils.ts b/src/frameworks/next/utils.ts index 50e02d020db..3bc9c245c64 100644 --- a/src/frameworks/next/utils.ts +++ b/src/frameworks/next/utils.ts @@ -4,7 +4,7 @@ import { basename, extname, join, posix, sep, resolve, dirname } from "path"; import { readFile } from "fs/promises"; import { glob, sync as globSync } from "glob"; import type { PagesManifest } from "next/dist/build/webpack/plugins/pages-manifest-plugin"; -import { coerce, satisfies } from "semver"; +import { coerce, satisfies, lt, gte, prerelease, parse } from "semver"; import { findDependency, isUrl, readJSON } from "../utils"; import type { @@ -426,6 +426,14 @@ export function getNextVersion(cwd: string): string | undefined { return nextVersionSemver.toString(); } +/** + * Get the raw Next.js version from the project. + */ +export function getNextVersionRaw(cwd: string): string | undefined { + const dependency = findDependency("next", { cwd, depth: 0, omitDev: false }); + return dependency?.version; +} + /** * Whether the Next.js project has a static `not-found` page in the app directory. * @@ -553,3 +561,52 @@ export function installEsbuild(version: string): void { } } } + +/** + * Check if the Next.js version is vulnerable to CVE-2025-66478. + * + * Vulnerable versions: + * - 15.0.x < 15.0.5 + * - 15.1.x < 15.1.9 + * - 15.2.x < 15.2.6 + * - 15.3.x < 15.3.6 + * - 15.4.x < 15.4.8 + * - 15.5.x < 15.5.7 + * - 16.0.x < 16.0.7 + * - 14.x canary >= 14.3.0-canary.77 + * + * @see https://nextjs.org/blog/CVE-2025-66478 + * @see https://www.cve.org/CVERecord?id=CVE-2025-55182 + * @see https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp + */ +export function isNextJsVersionVulnerable(versionStr: string): boolean { + const v = parse(versionStr); + if (!v) return false; + + if (v.major === 15) { + if (v.minor === 0) return lt(versionStr, "15.0.5"); + if (v.minor === 1) return lt(versionStr, "15.1.9"); + if (v.minor === 2) return lt(versionStr, "15.2.6"); + if (v.minor === 3) return lt(versionStr, "15.3.6"); + if (v.minor === 4) return lt(versionStr, "15.4.8"); + if (v.minor === 5) return lt(versionStr, "15.5.7"); + // Assume newer minor versions (e.g. 15.6.x) are safe as they should include the fix. + return false; + } + + if (v.major === 16) { + if (v.minor === 0) return lt(versionStr, "16.0.7"); + return false; + } + + if (v.major === 14) { + const pre = prerelease(versionStr); + if (pre && pre.includes("canary")) { + if (gte(versionStr, "14.3.0-canary.77")) { + return true; + } + } + } + + return false; +}