Skip to content

Commit 8d41585

Browse files
authored
[Breaking] feat(next/image)!: add support for images.dangerouslyAllowLocalIP and images.maximumRedirects (#84676)
This PR adds a two new options and sets a strict default value for each. - `images.dangerouslyAllowLocalIP` - `images.maximumRedirects` ### 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. However, this is not recommended for most users so the default is `false`. > [!NOTE] > BREAKING CHANGE: This change is breaking for those who self-hosting Next.js on a private network and want to allow optimizing images from local IP addresses on the same network. In those cases, you can still enable the config. ### maximumRedirects Since are also testing redirects for local IPs, we can also reduce the maximum number of redirects to 3 by default. Unlike normal websites which might redirect for features like auth, its unusual to have more than 3 redirects for an image. In some rare cases, developers may need to increase this value or set to `0` to disable redirects. > [!NOTE] > BREAKING CHANGE: This change is breaking for those who need image optimization to follow more than 3 redirects.
1 parent 8041a38 commit 8d41585

File tree

22 files changed

+266
-121
lines changed

22 files changed

+266
-121
lines changed

crates/next-build-test/nextConfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"disableStaticImages": false,
3131
"minimumCacheTTL": 60,
3232
"formats": ["image/avif", "image/webp"],
33+
"maximumRedirects": 3,
34+
"dangerouslyAllowLocalIP": false,
3335
"dangerouslyAllowSVG": false,
3436
"contentSecurityPolicy": "script-src 'none'; frame-src 'none'; sandbox;",
3537
"contentDispositionType": "inline",

docs/01-app/03-api-reference/02-components/image.mdx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,52 @@ module.exports = {
804804
}
805805
```
806806

807+
#### `maximumRedirects`
808+
809+
The default image optimization loader will follow HTTP redirects when fetching remote images up to 3 times.
810+
811+
```js filename="next.config.js"
812+
module.exports = {
813+
images: {
814+
maximumRedirects: 3,
815+
},
816+
}
817+
```
818+
819+
You can configure the number of redirects to follow when fetching remote images. Setting the value to `0` will disable following redirects.
820+
821+
```js filename="next.config.js"
822+
module.exports = {
823+
images: {
824+
maximumRedirects: 0,
825+
},
826+
}
827+
```
828+
829+
#### `dangerouslyAllowLocalIP`
830+
831+
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.
832+
833+
By default, the value is false.
834+
835+
```js filename="next.config.js"
836+
module.exports = {
837+
images: {
838+
dangerouslyAllowLocalIP: false,
839+
},
840+
}
841+
```
842+
843+
If you need to optimize remote images hosted elsewhere in your local network, you can set the value to true.
844+
845+
```js filename="next.config.js"
846+
module.exports = {
847+
images: {
848+
dangerouslyAllowLocalIP: true,
849+
},
850+
}
851+
```
852+
807853
#### `dangerouslyAllowSVG`
808854

809855
`dangerouslyAllowSVG` allows you to serve SVG images.
@@ -1284,7 +1330,7 @@ export default function Home() {
12841330

12851331
| Version | Changes |
12861332
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1287-
| `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated. |
1333+
| `v16.0.0` | `qualities` default configuration changed to `[75]`, `preload` prop added, `priority` prop deprecated, `dangerouslyAllowLocalIP` config added, `maximumRedirects` config added. |
12881334
| `v15.3.0` | `remotePatterns` added support for array of `URL` objects. |
12891335
| `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. |
12901336
| `v14.2.23` | `qualities` configuration added. |

packages/next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@
270270
"ignore-loader": "0.1.2",
271271
"image-size": "1.2.1",
272272
"is-docker": "2.0.0",
273+
"is-local-address": "2.2.2",
273274
"is-wsl": "2.2.0",
274275
"jest-worker": "27.5.1",
275276
"json5": "2.2.3",

packages/next/src/compiled/is-local-address/index.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"is-local-address","main":"index.js","author":{"email":"josefrancisco.verdu@gmail.com","name":"Kiko Beats","url":"https://kikobeats.com"},"license":"MIT"}

packages/next/src/server/config-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
551551
contentSecurityPolicy: z.string().optional(),
552552
contentDispositionType: z.enum(['inline', 'attachment']).optional(),
553553
dangerouslyAllowSVG: z.boolean().optional(),
554+
dangerouslyAllowLocalIP: z.boolean().optional(),
554555
deviceSizes: z
555556
.array(z.number().int().gte(1).lte(10000))
556557
.max(25)
@@ -568,6 +569,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
568569
.optional(),
569570
loader: z.enum(VALID_LOADERS).optional(),
570571
loaderFile: z.string().optional(),
572+
maximumRedirects: z.number().int().min(0).max(20).optional(),
571573
minimumCacheTTL: z.number().int().gte(0).optional(),
572574
path: z.string().optional(),
573575
qualities: z

packages/next/src/server/image-optimizer.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import contentDisposition from 'next/dist/compiled/content-disposition'
66
import imageSizeOf from 'next/dist/compiled/image-size'
77
import { detector } from 'next/dist/compiled/image-detector/detector.js'
88
import isAnimated from 'next/dist/compiled/is-animated'
9+
import isLocalAddress from 'next/dist/compiled/is-local-address'
910
import { join } from 'path'
1011
import nodeUrl, { type UrlWithParsedQuery } from 'url'
1112

@@ -30,6 +31,9 @@ import isError from '../lib/is-error'
3031
import { parseUrl } from '../lib/url'
3132
import type { CacheControl } from './lib/cache-control'
3233
import { InvariantError } from '../shared/lib/invariant-error'
34+
import { lookup } from 'dns/promises'
35+
import { isIP } from 'net'
36+
import { ALL } from 'dns'
3337

3438
type XCacheHeader = 'MISS' | 'HIT' | 'STALE'
3539

@@ -700,9 +704,40 @@ export async function optimizeImage({
700704
return optimizedBuffer
701705
}
702706

703-
export async function fetchExternalImage(href: string): Promise<ImageUpstream> {
707+
function isRedirect(statusCode: number) {
708+
return [301, 302, 303, 307, 308].includes(statusCode)
709+
}
710+
711+
export async function fetchExternalImage(
712+
href: string,
713+
dangerouslyAllowLocalIP: boolean,
714+
count = 3
715+
): Promise<ImageUpstream> {
716+
if (!dangerouslyAllowLocalIP) {
717+
const { hostname } = new URL(href)
718+
let ips = [hostname]
719+
if (!isIP(hostname)) {
720+
const records = await lookup(hostname, {
721+
family: 0,
722+
all: true,
723+
hints: ALL,
724+
}).catch((_) => [{ address: hostname }])
725+
ips = records.map((record) => record.address)
726+
}
727+
const privateIps = ips.filter((ip) => isLocalAddress(ip))
728+
if (privateIps.length > 0) {
729+
Log.error(
730+
'upstream image',
731+
href,
732+
'resolved to private ip',
733+
JSON.stringify(privateIps)
734+
)
735+
throw new ImageError(400, '"url" parameter is not allowed')
736+
}
737+
}
704738
const res = await fetch(href, {
705739
signal: AbortSignal.timeout(7_000),
740+
redirect: 'manual',
706741
}).catch((err) => err as Error)
707742

708743
if (res instanceof Error) {
@@ -717,6 +752,23 @@ export async function fetchExternalImage(href: string): Promise<ImageUpstream> {
717752
throw err
718753
}
719754

755+
const locationHeader = res.headers.get('Location')
756+
if (
757+
isRedirect(res.status) &&
758+
locationHeader &&
759+
URL.canParse(locationHeader, href)
760+
) {
761+
if (count === 0) {
762+
Log.error('upstream image response had too many redirects', href)
763+
throw new ImageError(
764+
508,
765+
'"url" parameter is valid but upstream response is invalid'
766+
)
767+
}
768+
const redirect = new URL(locationHeader, href).href
769+
return fetchExternalImage(redirect, dangerouslyAllowLocalIP, count - 1)
770+
}
771+
720772
if (!res.ok) {
721773
Log.error('upstream image response failed for', href, res.status)
722774
throw new ImageError(

packages/next/src/server/next-server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,11 @@ export default class NextNodeServer extends BaseServer<
780780
const { isAbsolute, href } = paramsResult
781781

782782
const imageUpstream = isAbsolute
783-
? await fetchExternalImage(href)
783+
? await fetchExternalImage(
784+
href,
785+
this.nextConfig.images.dangerouslyAllowLocalIP,
786+
this.nextConfig.images.maximumRedirects
787+
)
784788
: await fetchInternalImage(
785789
href,
786790
req.originalRequest,

packages/next/src/shared/lib/image-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ export type ImageConfigComplete = {
103103
/** @see [Acceptable formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) */
104104
formats: ImageFormat[]
105105

106+
/** @see [Maximum Redirects](https://nextjs.org/docs/api-reference/next/image#maximumredirects) */
107+
maximumRedirects: number
108+
109+
/** @see [Dangerously Allow Local IP](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-local-ip) */
110+
dangerouslyAllowLocalIP: boolean
111+
106112
/** @see [Dangerously Allow SVG](https://nextjs.org/docs/api-reference/next/image#dangerously-allow-svg) */
107113
dangerouslyAllowSVG: boolean
108114

@@ -140,6 +146,8 @@ export const imageConfigDefault: ImageConfigComplete = {
140146
disableStaticImages: false,
141147
minimumCacheTTL: 14400, // 4 hours
142148
formats: ['image/webp'],
149+
maximumRedirects: 3,
150+
dangerouslyAllowLocalIP: false,
143151
dangerouslyAllowSVG: false,
144152
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
145153
contentDispositionType: 'attachment',

packages/next/taskfile.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,14 @@ export async function ncc_is_animated(task, opts) {
12751275
.target('src/compiled/is-animated')
12761276
}
12771277
// eslint-disable-next-line camelcase
1278+
externals['is-local-address'] = 'next/dist/compiled/is-local-address'
1279+
export async function ncc_is_local_address(task, opts) {
1280+
await task
1281+
.source(relative(__dirname, require.resolve('is-local-address')))
1282+
.ncc({ packageName: 'is-local-address', externals })
1283+
.target('src/compiled/is-local-address')
1284+
}
1285+
// eslint-disable-next-line camelcase
12781286
externals['is-docker'] = 'next/dist/compiled/is-docker'
12791287
export async function ncc_is_docker(task, opts) {
12801288
await task
@@ -2349,6 +2357,7 @@ export async function ncc(task, opts) {
23492357
'ncc_http_proxy',
23502358
'ncc_ignore_loader',
23512359
'ncc_is_animated',
2360+
'ncc_is_local_address',
23522361
'ncc_is_docker',
23532362
'ncc_is_wsl',
23542363
'ncc_json5',

0 commit comments

Comments
 (0)