Next.js: Unbounded next/image disk cache growth can exhaust storage
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
## Summary The default Next.js image optimization disk cache (`/_next/image`) did not have a configurable upper bound, allowing unbounded cache growth. ## Impact An attacker could generate many unique image-optimization variants and exhaust disk space, causing denial of service. Note that this does not impact platforms that have their own image optimization capabilities, such as Vercel. ## Patches Fixed by adding an LRU-backed disk cache with `images.maximumDiskCacheSize`, including eviction of least-recently-used entries when the limit is exceeded. Setting `maximumDiskCacheSize: 0` disables disk caching. ## Workarounds If upgrade is not immediately possible: - Periodically clean `.next/cache/images`. - Reduce variant cardinality (e.g., tighten values for `images.localPatterns`, `images.remotePatterns`, and `images.qualities`)
The fix
feat(next/image): add lru disk cache and
docs/01-app/03-api-reference/02-components/image.mdx+31 −0
@@ -837,6 +837,36 @@ module.exports = {}```+#### `maximumDiskCacheSize`++The default image optimization loader will write optimized images to disk so subsequent requests can be served faster from the disk cache.++You can configure the maximum disk cache size in bytes, for example 500 MB:++```js filename="next.config.js"+module.exports = {+images: {+maximumDiskCacheSize: 500_000_000,+},+}+```++You can also disable the disk cache entirely by setting the value to `0`.++```js filename="next.config.js"+module.exports = {+images: {+maximumDiskCacheSize: 0,+},+}+```++If no value is configured, the default behavior is to check the current available disk space once during startup and use 50%.++When the disk cache exceeds the configured size, the least recently used optimized images will be deleted until the cache is under the limit again.++Alternatively, you can implement your own cache handler using [`cacheHandler`](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) which will ignore the `maximumDiskCacheSize` configuration.+#### `maximumResponseBody`The default image optimization loader will fetch source images up to 50 MB in size.@@ -1363,6 +1393,7 @@ export default function Home() {| Version | Changes || ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |+| `v16.1.7` | `maximumDiskCacheSize` configuration added. || `v16.1.2` | `maximumResponseBody` configuration added. || `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated, `dangerouslyAllowLocalIP` config added, `maximumRedirects` config added. || `v15.3.0` | `remotePatterns` added support for array of `URL` objects. |
packages/next/errors.json+3 −1
@@ -1066,5 +1066,7 @@"1065": "createServerPathnameForMetadata should not be called in client contexts.","1066": "createServerSearchParamsForServerPage should not be called in a client validation.","1067": "The Next.js unhandled rejection filter is being installed more than once. This is a bug in Next.js.",-"1068": "Expected workStore to be initialized"+"1068": "Expected workStore to be initialized",+"1069": "Invariant: cache entry \"%s\" not found in dir \"%s\"",+"1070": "image of size %s could not be tracked by lru cache"}
packages/next/src/server/config-schema.ts+1 −0
@@ -623,6 +623,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>.optional(),loader: z.enum(VALID_LOADERS).optional(),loaderFile: z.string().optional(),+maximumDiskCacheSize: z.number().int().min(0).optional(),maximumRedirects: z.number().int().min(0).max(20).optional(),maximumResponseBody: z.number()
packages/next/src/server/image-optimizer.ts+111 −31
@@ -30,6 +30,7 @@ import { getContentType, getExtension } from './serve-static'import * as Log from '../build/output/log'import isError from '../lib/is-error'import { isPrivateIp } from './is-private-ip'+import { getOrInitDiskLRU } from './lib/disk-lru-cache.external'import { parseUrl } from '../lib/url'import type { CacheControl } from './lib/cache-control'import { InvariantError } from '../shared/lib/invariant-error'@@ -61,6 +62,29 @@ const BLUR_QUALITY = 70 // should match `next-image-loader`let _sharp: typeof import('sharp')+async function initCacheEntries(+cacheDir: string+): Promise<Array<{ key: string; size: number; expireAt: number }>> {+const cacheKeys = await promises.readdir(cacheDir).catch(() => [])+const entries: Array<{ key: string; size: number; expireAt: number }> = []++for (const cacheKey of cacheKeys) {+try {+const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey)+entries.push({+key: cacheKey,+size: buffer.byteLength,+expireAt,+})+} catch {+// Skip entries that can't be read from disk+}+}++// Sort oldest-first so we can replay them chronologically into LRU+return entries.sort((a, b) => a.expireAt - b.expireAt)+}+export function getSharp(concurrency: number | null | undefined) {if (_sharp) {return _sharp@@ -139,7 +163,8 @@ export function getImageEtag(image: Buffer) {}async function writeToCacheDir(-dir: string,+cacheDir: string,+cacheKey: string,extension: string,maxAge: number,expireAt: number,@@ -147,6 +172,7 @@ async function writeToCacheDir(etag: string,upstreamEtag: string) {+const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)const filename = join(/* turbopackIgnore: true */dir,@@ -159,6 +185,37 @@ async function writeToCacheDir(await promises.writeFile(filename, buffer)}+async function readFromCacheDir(cacheDir: string, cacheKey: string) {+const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)+const files = await promises.readdir(dir)+const file = files[0]+if (!file) {+throw new Error(+`Invariant: cache entry "${cacheKey}" not found in dir "${cacheDir}"`+)+}+const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] = file.split(+'.',+5+)+const filePath = join(/* turbopackIgnore: true */ dir, file)+const buffer = await promises.readFile(/* turbopackIgnore: true */ filePath)+const expireAt = Number(expireAtSt)+const maxAge = Number(maxAgeSt)+return { maxAge, expireAt, etag, upstreamEtag, buffer, extension }+}++async function deleteFromCacheDir(cacheDir: string, cacheKey: string) {+return promises+.rm(join(/* turbopackIgnore: true */ cacheDir, cacheKey), {+recursive: true,+force: true,+})+.catch((err) => {+Log.error(`Failed to delete cache key ${cacheKey}`, err)+})+}+/*** Inspects the first few bytes of a buffer to determine if* it matches the "magic number" of known file signatures.@@ -318,6 +375,8 @@ export class ImageOptimizerCache {private cacheDir: stringprivate nextConfig: NextConfigRuntimeprivate cacheHandler?: CacheHandler+private cacheDiskLRU?: ReturnType<typeof getOrInitDiskLRU>+private isDiskCacheEnabled?: booleanstatic validateParams(req: IncomingMessage,@@ -507,6 +566,21 @@ export class ImageOptimizerCache {this.cacheDir = join(/* turbopackIgnore: true */ distDir, 'cache', 'images')this.nextConfig = nextConfigthis.cacheHandler = cacheHandler++// Eagerly start LRU initialization for filesystem cache+if (+!cacheHandler &&+nextConfig.images.maximumDiskCacheSize !== 0 &&+nextConfig.experimental.isrFlushToDisk+) {+this.isDiskCacheEnabled = true+this.cacheDiskLRU = getOrInitDiskLRU(+this.cacheDir,+nextConfig.images.maximumDiskCacheSize,+initCacheEntries,+deleteFromCacheDir+)+}}async get(cacheKey: string): Promise<IncrementalResponseCacheEntry | null> {@@ -549,38 +623,34 @@ export class ImageOptimizerCache {return null}+// If the filesystem cache is disabled, return early+if (!this.isDiskCacheEnabled) {+return null+}+// Fall back to filesystem cachetry {-const cacheDir = join(/* turbopackIgnore: true */ this.cacheDir, cacheKey)-const files = await promises.readdir(cacheDir)const now = Date.now()+const { maxAge, expireAt, etag, upstreamEtag, buffer, extension } =+await readFromCacheDir(this.cacheDir, cacheKey)-for (const file of files) {-const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] =-file.split('.', 5)-const buffer = await promises.readFile(-/* turbopackIgnore: true */ join(-/* turbopackIgnore: true */ cacheDir,-file-)-)-const expireAt = Number(expireAtSt)-const maxAge = Number(maxAgeSt)+// Promote entry in LRU (mark as recently used)+const lru = await this.cacheDiskLRU+lru?.get(cacheKey)-return {-value: {-kind: CachedRouteKind.IMAGE,-etag,-buffer,-extension,-upstreamEtag,-},-revalidateAfter:-Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 +-Date.now(),-cacheControl: { revalidate: maxAge, expire: undefined },-isStale: now > expireAt,-}+return {+value: {+kind: CachedRouteKind.IMAGE,+etag,+buffer,+extension,+upstreamEtag,+},+revalidateAfter:+Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 ++Date.now(),+cacheControl: { revalidate: maxAge, expire: undefined },+isStale: now > expireAt,}} catch (_) {// failed to read from cache dir, treat as cache miss@@ -630,18 +700,28 @@ export class ImageOptimizerCache {return}-// Fall back to filesystem cache-if (!this.nextConfig.experimental.isrFlushToDisk) {+// If the filesystem cache is disabled, return early+if (!this.isDiskCacheEnabled) {return}+// Fall back to filesystem cacheconst expireAt =Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 +Date.now()try {+const lru = await this.cacheDiskLRU+const success = lru?.set(cacheKey, value.buffer.byteLength)+if (success === false) {+throw new Error(+`image of size ${value.buffer.byteLength} could not be tracked by lru cache`+)+}+await writeToCacheDir(-join(/* turbopackIgnore: true */ this.cacheDir, cacheKey),+this.cacheDir,+cacheKey,value.extension,revalidate,expireAt,
packages/next/src/server/lib/disk-lru-cache.external.ts+60 −0
@@ -0,0 +1,60 @@+import { promises } from 'fs'+import { LRUCache } from './lru-cache'++/**+* Module-level LRU singleton for disk cache eviction.+* Initialized once on first `set()`, shared across all consumers.+* Once resolved, the promise stays resolved — subsequent calls just await the cached result.+*/+let _diskLRUPromise: Promise<LRUCache<number>> | null = null++/**+* Initialize or return the module-level LRU for disk cache eviction.+* Concurrent calls are deduplicated via the shared promise.+*+* @param cacheDir - The directory where cached files are stored+* @param maxDiskSize - Maximum disk cache size in bytes+* @param readEntries - Callback to scan existing cache entries (format-agnostic)+*/+export async function getOrInitDiskLRU(+cacheDir: string,+maxDiskSize: number | undefined,+readEntries: (+cacheDir: string+) => Promise<Array<{ key: string; size: number; expireAt: number }>>,+evictEntry: (cacheDir: string, cacheKey: string) => Promise<void>+): Promise<LRUCache<number>> {+if (!_diskLRUPromise) {+_diskLRUPromise = (async () => {+let maxSize = maxDiskSize+if (typeof maxSize === 'undefined') {+// Ensure cacheDir exists before checking disk space+await promises.mkdir(cacheDir, { recursive: true })+// Since config was not provided, default to 50% of available disk space+const { bavail, bsize } = await promises.statfs(cacheDir)+maxSize = Math.floor((bavail * bsize) / 2)+}++const lru = new LRUCache<number>(+maxSize,+(size) => size,+(cacheKey) => evictEntry(cacheDir, cacheKey)+)++const entries = await readEntries(cacheDir)+for (const entry of entries) {+lru.set(entry.key, entry.size)+}++return lru+})()+}+return _diskLRUPromise+}++/**+* Reset the module-level LRU singleton. Exported for testing only.+*/+export function resetDiskLRU(): void {+_diskLRUPromise = null+}
packages/next/src/server/lib/lru-cache.test.ts+25 −4
@@ -9,7 +9,7 @@ describe('LRUCache', () => {})it('should set and get values', () => {-cache.set('key1', 'value1')+expect(cache.set('key1', 'value1')).toBe(true)expect(cache.get('key1')).toBe('value1')})@@ -105,11 +105,11 @@ describe('LRUCache', () => {expect(cache.currentSize).toBe(8) // 5 + 2 + 1})-it('should handle items larger than max size', () => {+it('should prevent adding item larger than max size when lru is empty', () => {const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()const cache = new LRUCache<string>(5, (value) => value.length)-cache.set('key1', 'toolarge') // size 8 > maxSize 5+expect(cache.set('key1', 'toolarge')).toBe(false) // size 8 > maxSize 5expect(cache.has('key1')).toBe(false)expect(cache.size).toBe(0)@@ -120,6 +120,27 @@ describe('LRUCache', () => {consoleSpy.mockRestore()})+it('should prevent adding item larger than max size when lru is not empty', () => {+const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()+const cache = new LRUCache<string>(5, (value) => value.length)++expect(cache.set('key1', 'ab')).toBe(true) // size 2+expect(cache.set('key2', 'cd')).toBe(true) // size 2, total = 4++expect(cache.set('key3', 'toolarge')).toBe(false) // size 8 > maxSize 5, should be rejected++expect(cache.has('key1')).toBe(true)+expect(cache.has('key2')).toBe(true)+expect(cache.has('key3')).toBe(false)+expect(cache.size).toBe(2)+expect(cache.currentSize).toBe(4)+expect(consoleSpy).toHaveBeenCalledWith(+'Single item size exceeds maxSize'+)++consoleSpy.mockRestore()+})+it('should update size when overwriting existing keys', () => {const cache = new LRUCache<string>(10, (value) => value.length)@@ -184,7 +205,7 @@ describe('LRUCache', () => {describe('Edge Cases', () => {it('should handle zero max size', () => {const cache = new LRUCache<string>(0)-cache.set('key1', 'value1')+expect(cache.set('key1', 'value1')).toBe(false)expect(cache.has('key1')).toBe(false)expect(cache.size).toBe(0)})
packages/next/src/server/lib/lru-cache.ts+4 −2
@@ -123,7 +123,7 @@ export class LRUCache<T> {* - O(1) for uniform item sizes* - O(k) where k is the number of items evicted (can be O(N) for variable sizes)*/-public set(key: string, value: T): void {+public set(key: string, value: T): boolean {const size = this.calculateSize?.(value) ?? 1if (size <= 0) {throw new Error(@@ -133,7 +133,7 @@ export class LRUCache<T> {}if (size > this.maxSize) {console.warn('Single item size exceeds maxSize')-return+return false}const existing = this.cache.get(key)@@ -158,6 +158,8 @@ export class LRUCache<T> {this.totalSize -= tail.sizethis.onEvict?.(tail.key, tail.data)}++return true}/**
packages/next/src/shared/lib/image-config.ts+4 −0
@@ -103,6 +103,9 @@ export type ImageConfigComplete = {/** @see [Acceptable formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) */formats: ImageFormat[]+/** @see [Maximum Disk Cache Size (in bytes)](https://nextjs.org/docs/api-reference/next/image#maximumdiskcachesize) */+maximumDiskCacheSize: number | undefined+/** @see [Maximum Redirects](https://nextjs.org/docs/api-reference/next/image#maximumredirects) */maximumRedirects: number@@ -156,6 +159,7 @@ export const imageConfigDefault: ImageConfigComplete = {disableStaticImages: false,minimumCacheTTL: 14400, // 4 hoursformats: ['image/webp'],+maximumDiskCacheSize: undefined, // auto-detect by defaultmaximumRedirects: 3,maximumResponseBody: 50_000_000, // 50 MBdangerouslyAllowLocalIP: false,
References
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-3x4c-7xq6-9pq8
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-27980
- WEBhttps://github.com/vercel/next.js/commit/39eb8e0ac498b48855a0430fbf4c22276a73b4bd
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v16.1.7