Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/next-build-test/nextConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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;",
Expand Down
23 changes: 23 additions & 0 deletions docs/01-app/03-api-reference/02-components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`. |
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,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
Expand Down
38 changes: 36 additions & 2 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ function isRedirect(statusCode: number) {
export async function fetchExternalImage(
href: string,
dangerouslyAllowLocalIP: boolean,
maximumResponseBody: number,
count = 3
): Promise<ImageUpstream> {
if (!dangerouslyAllowLocalIP) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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'

@ztanner ztanner Jan 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this and the other errors be more explicit that it's a max size issue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are all generic like this and the server log provides more information

)
}

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)
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,7 @@ export default class NextNodeServer extends BaseServer<
? await fetchExternalImage(
href,
this.nextConfig.images.dangerouslyAllowLocalIP,
this.nextConfig.images.maximumResponseBody,
this.nextConfig.images.maximumRedirects
)
Comment thread
styfle marked this conversation as resolved.
: await fetchInternalImage(
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/shared/lib/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [42, 69, 88],
Expand Down
1 change: 1 addition & 0 deletions test/integration/next-image-new/app-dir/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,7 @@ function runTests(mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
Expand Down
1 change: 1 addition & 0 deletions test/integration/next-image-new/unicode/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function runTests(mode: 'server' | 'dev') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function runTests(url: string, mode: 'dev' | 'server') {
},
],
maximumRedirects: 3,
maximumResponseBody: 300000000,
minimumCacheTTL: 14400,
path: '/_next/image',
qualities: [75],
Expand Down
188 changes: 188 additions & 0 deletions test/unit/image-optimizer/fetch-external-image.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading