From 1fa33f2aee8a7fa97b9e3bf7a13ee1ebdd95f15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:49 +0200 Subject: [PATCH] fix(security): turn the image optimizer off Nothing renders through next/image, so /_next/image only offered a route that fetches, decodes and re-encodes whatever local path a request names. With images.unoptimized Next answers it with 404 before reading a parameter. A guard fails when next/image is imported while the optimizer is off, or when the optimizer returns with SVG input allowed. --- next.config.ts | 10 ++ .../next-image-optimizer-guard.test.ts | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/__tests__/next-image-optimizer-guard.test.ts diff --git a/next.config.ts b/next.config.ts index a844e2081..055b1dcf7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -35,6 +35,16 @@ const nextConfig: NextConfig = { // still covers the full program. typescript: { tsconfigPath: "tsconfig.build.json" }, poweredByHeader: false, + // The image optimizer is off. Nothing renders through `next/image`: logos, + // icons and avatars are plain `` elements served from `public/` or + // from an authenticated route. With the optimizer on, `/_next/image` still + // fetched, decoded and re-encoded whatever local path a request named, + // which is surface this app has no use for and where the framework has + // shipped critical advisories. Off, Next answers that route with 404 + // before it reads a single parameter. `next-image-optimizer-guard.test.ts` + // holds this: it fails when `next/image` is imported while the optimizer + // is off, and when the optimizer comes back with SVG input allowed. + images: { unoptimized: true }, // v1.4.38.4 — expose the package.json version to the client bundle // so the `` can compare the shell-baked version // against the live `/api/version` response and trigger a self-heal diff --git a/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts new file mode 100644 index 000000000..16f8a3124 --- /dev/null +++ b/src/__tests__/next-image-optimizer-guard.test.ts @@ -0,0 +1,113 @@ +/** + * The image optimizer stays off while nothing uses it. + * + * `next.config.ts` sets `images.unoptimized`, which makes Next answer + * `/_next/image` with 404 before it reads a parameter, so the route that + * fetches, decodes and re-encodes images is not reachable at all. Two ways + * to undo that quietly, and this pins both: + * + * 1. A component imports `next/image` (or `next/legacy/image`) while the + * optimizer is off. It would render, unoptimized, and the next person + * to notice the missing optimization would switch the optimizer back on + * without the review this flag deserves. Using `next/image` means + * revisiting this decision, so the import fails here first. + * 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a + * document, not a picture, and the framework's own advisories have been + * about exactly that input. Re-enabling the optimizer is allowed; doing + * it with SVG input allowed is not. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +type ImagesConfig = { + unoptimized?: boolean; + dangerouslyAllowSVG?: boolean; +}; + +async function imagesConfig(): Promise { + const mod = (await import("../../next.config")) as { + default: { images?: ImagesConfig }; + }; + return mod.default.images ?? {}; +} + +/** Matches any import of `specifier` (static, dynamic, `require`, re-export). */ +function importOf(specifier: string): RegExp { + const escaped = specifier.replace(/[/]/g, "\\/"); + return new RegExp( + `(?:\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|^\\s*import\\s+)["']${escaped}["']`, + "m", + ); +} + +const NEXT_IMAGE_IMPORT = importOf("next/(?:legacy/)?image"); + +/** Every non-generated, non-test source file under `src/` matching `re`. */ +function importers(re: RegExp): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => !rel.includes("__tests__/")) + .filter((rel) => + re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))), + ); +} + +describe("next/image optimizer", () => { + it("the import matcher recognises every form of the import", () => { + // The sweep below passes when it matches nothing, so the matcher has to + // be shown to match before an empty result means anything. + for (const line of [ + 'import Image from "next/image";', + "import Image from 'next/legacy/image';", + 'const Image = await import("next/image");', + 'const Image = require("next/image");', + 'import "next/image";', + 'export { default } from "next/image";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true); + } + for (const line of [ + "// `next/image` is wrong here", + 'const path = "/_next/image?url=x";', + 'import { foo } from "next/image-extra";', + ]) { + expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false); + } + }); + + it("the same sweep finds the imports of a sibling module", () => { + // An empty result for `next/image` is only worth something if the walk, + // the comment stripping and the matcher together do find real imports. + // `next/link` is imported the same way all over the tree. + requireFloor("next/link importers", importers(importOf("next/link")), 100); + }); + + it("is off, and then nothing imports next/image", async () => { + const images = await imagesConfig(); + if (images.unoptimized !== true) return; + expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]); + }); + + it("is never on with SVG input allowed", async () => { + const images = await imagesConfig(); + if (images.unoptimized === true) return; + expect(images.dangerouslyAllowSVG ?? false).toBe(false); + }); + + it("is off today", async () => { + // The two cases above each skip on the other branch. This pins which + // branch the tree is on, so flipping it is a visible change to this + // file and not a silent switch between the two. + expect((await imagesConfig()).unoptimized).toBe(true); + }); +});