mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user