feat(next/image)!: add images.maximumResponseBody config (#88183)

Add `images.maximumResponseBody` configuration to exit early when
attempting to optimize large source images.
This commit is contained in:
Steven
2026-01-06 14:49:45 -08:00
committed by GitHub
parent fe34ade9b5
commit 00e92280d2
12 changed files with 264 additions and 2 deletions
+1
View File
@@ -31,6 +31,7 @@
"minimumCacheTTL": 60,
"formats": ["image/avif", "image/webp"],
"maximumRedirects": 3,
"maximumResponseBody": 300000000,
"dangerouslyAllowLocalIP": false,
"dangerouslyAllowSVG": false,
"contentSecurityPolicy": "script-src 'none'; frame-src 'none'; sandbox;",
@@ -837,6 +837,28 @@ module.exports = {
}
```
#### `maximumResponseBody`
The default image optimization loader will fetch source images up to 300 MB in size.
```js filename="next.config.js"
module.exports = {
images: {
maximumResponseBody: 300_000_000,
},
}
```
If you know all your source images are small, you can protect memory constrained servers by reducing this to a smaller value such as 50 MB.
```js filename="next.config.js"
module.exports = {
images: {
maximumResponseBody: 50_000_000,
},
}
```
#### `dangerouslyAllowLocalIP`
In rare cases when self-hosting Next.js on a private network, you may want to allow optimizing images from local IP addresses on the same network. This is not recommended for most users because it could allow malicious users to access content on your internal network.
@@ -1341,6 +1363,7 @@ export default function Home() {
| Version | Changes |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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. |
| `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. |
@@ -588,6 +588,12 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
loader: z.enum(VALID_LOADERS).optional(),
loaderFile: z.string().optional(),
maximumRedirects: z.number().int().min(0).max(20).optional(),
maximumResponseBody: z
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.optional(),
minimumCacheTTL: z.number().int().gte(0).optional(),
path: z.string().optional(),
qualities: z
+36 -2
View File
@@ -711,6 +711,7 @@ function isRedirect(statusCode: number) {
export async function fetchExternalImage(
href: string,
dangerouslyAllowLocalIP: boolean,
maximumResponseBody: number,
count = 3
): Promise<ImageUpstream> {
if (!dangerouslyAllowLocalIP) {
@@ -766,7 +767,12 @@ export async function fetchExternalImage(
)
}
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(redirect, dangerouslyAllowLocalIP, count - 1)
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
}
if (!res.ok) {
@@ -777,7 +783,35 @@ export async function fetchExternalImage(
)
}
const buffer = Buffer.from(await res.arrayBuffer())
if (!res.body) {
Log.error('upstream image response is empty for', href)
throw new ImageError(
400,
'"url" parameter is valid but upstream response is invalid'
)
}
const chunks: Buffer[] = []
let totalSize = 0
for await (const c of res.body) {
const chunk = Buffer.from(c)
totalSize += chunk.byteLength
if (totalSize > maximumResponseBody) {
Log.error(
'upstream image response exceeded maximum size for',
href,
totalSize
)
throw new ImageError(
413,
'"url" parameter is valid but upstream response is invalid'
)
}
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
const contentType = res.headers.get('Content-Type')
const cacheControl = res.headers.get('Cache-Control')
const etag = extractEtag(res.headers.get('ETag'), buffer)
+1
View File
@@ -785,6 +785,7 @@ export default class NextNodeServer extends BaseServer<
? await fetchExternalImage(
href,
this.nextConfig.images.dangerouslyAllowLocalIP,
this.nextConfig.images.maximumResponseBody,
this.nextConfig.images.maximumRedirects
)
: await fetchInternalImage(
@@ -106,6 +106,9 @@ export type ImageConfigComplete = {
/** @see [Maximum Redirects](https://nextjs.org/docs/api-reference/next/image#maximumredirects) */
maximumRedirects: number
/** @see [Maximum Response Body](https://nextjs.org/docs/api-reference/next/image#maximumresponsebody) */
maximumResponseBody: number
/** @see [Dangerously Allow Local IP](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-local-ip) */
dangerouslyAllowLocalIP: boolean
@@ -147,6 +150,7 @@ export const imageConfigDefault: ImageConfigComplete = {
minimumCacheTTL: 14400, // 4 hours
formats: ['image/webp'],
maximumRedirects: 3,
maximumResponseBody: 300_000_000, // 300MB
dangerouslyAllowLocalIP: false,
dangerouslyAllowSVG: false,
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
@@ -98,6 +98,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
@@ -110,6 +110,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [42, 69, 88],
@@ -1796,6 +1796,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
@@ -103,6 +103,7 @@ function runTests(mode: 'server' | 'dev') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
@@ -118,6 +118,7 @@ function runTests(url: string, mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
@@ -0,0 +1,188 @@
/* eslint-env jest */
import {
fetchExternalImage,
ImageError,
} from 'next/dist/server/image-optimizer'
describe('fetchExternalImage', () => {
describe('response size limit', () => {
it('should throw error when response has no body', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
body: null,
headers: {
get: jest.fn(() => null),
},
})
const error = await fetchExternalImage(
'http://example.com/no-body.jpg',
false,
300_000_000
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(400)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should throw error when exceeding maximumResponseBody config on later chunk', async () => {
const maximumResponseBody = 2_000 // 2KB custom limit
const chunkSize = 1_000 // 1KB chunks
const numChunks = 3 // 3KB total, exceeds custom 2KB limit
global.fetch = jest.fn().mockImplementation(() => {
let chunksRead = 0
const mockReadableStream = new ReadableStream({
async pull(controller) {
if (chunksRead < numChunks) {
controller.enqueue(new Uint8Array(chunkSize))
chunksRead++
} else {
controller.close()
}
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const error = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(413)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should throw error when exceeding maximumResponseBody config on first chunk', async () => {
const maximumResponseBody = 2_000 // 2KB custom limit
global.fetch = jest.fn().mockImplementation(() => {
const mockReadableStream = new ReadableStream({
async pull(controller) {
controller.enqueue(new Uint8Array(maximumResponseBody + 1))
controller.close()
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const error = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(413)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should succeed when exactly matching maximumResponseBody config on first chunk', async () => {
const maximumResponseBody = 3_000 // 3KB custom limit
global.fetch = jest.fn().mockImplementation(() => {
const mockReadableStream = new ReadableStream({
async pull(controller) {
controller.enqueue(new Uint8Array(maximumResponseBody))
controller.close()
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const result = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
)
expect(result.buffer).toBeInstanceOf(Buffer)
expect(result.buffer.length).toBe(maximumResponseBody)
})
it('should succeed when exactly matching maximumResponseBody config on later chunk', async () => {
const maximumResponseBody = 3_000 // 3KB custom limit
const chunkSize = 1_000 // 1KB chunks
const numChunks = 3 // 3KB total
global.fetch = jest.fn().mockImplementation(() => {
let chunksRead = 0
const mockReadableStream = new ReadableStream({
async pull(controller) {
if (chunksRead < numChunks) {
controller.enqueue(new Uint8Array(chunkSize))
chunksRead++
} else {
controller.close()
}
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const result = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
)
expect(result.buffer).toBeInstanceOf(Buffer)
expect(result.buffer.length).toBe(maximumResponseBody)
})
})
})