Security context
MediumGHSA-3x4c-7xq6-9pq8 CVE-2026-27980CWE-400Published Mar 17, 2026

Next.js: Unbounded next/image disk cache growth can exhaust storage

Research this vulnerability

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

16.0.0-beta.0 → fixed in 16.1.710.0.0 → fixed in 15.5.14

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

Steven· Feb 20, 2026, 08:12 PM+2393839eb8e0ac4
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: string
private nextConfig: NextConfigRuntime
private cacheHandler?: CacheHandler
+ private cacheDiskLRU?: ReturnType<typeof getOrInitDiskLRU>
+ private isDiskCacheEnabled?: boolean
static validateParams(
req: IncomingMessage,
@@ -507,6 +566,21 @@ export class ImageOptimizerCache {
this.cacheDir = join(/* turbopackIgnore: true */ distDir, 'cache', 'images')
this.nextConfig = nextConfig
this.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 cache
try {
- 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 cache
const 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 5
expect(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) ?? 1
if (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.size
this.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 hours
formats: ['image/webp'],
+ maximumDiskCacheSize: undefined, // auto-detect by default
maximumRedirects: 3,
maximumResponseBody: 50_000_000, // 50 MB
dangerouslyAllowLocalIP: false,
More files changed — see the full commit.

References