Skip to content

Commit e03c4ff

Browse files
authored
feat(next/image): add images.localPatterns config (#70802)
- Backport #70529 to `14.2.x`
1 parent 540ea2d commit e03c4ff

File tree

33 files changed

+598
-23
lines changed

33 files changed

+598
-23
lines changed

docs/02-app/01-building-your-application/06-optimizing/01-images.mdx

+15
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ export default function Page() {
8989

9090
> **Warning:** Dynamic `await import()` or `require()` are _not_ supported. The `import` must be static so it can be analyzed at build time.
9191
92+
You can optionally configure `localPatterns` in your `next.config.js` file in order to allow specific images and block all others.
93+
94+
```js filename="next.config.js"
95+
module.exports = {
96+
images: {
97+
localPatterns: [
98+
{
99+
pathname: '/assets/images/**',
100+
search: '',
101+
},
102+
],
103+
},
104+
}
105+
```
106+
92107
### Remote Images
93108

94109
To use a remote image, the `src` property should be a URL string.

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

+19
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,25 @@ Other properties on the `<Image />` component will be passed to the underlying
484484

485485
In addition to props, you can configure the Image Component in `next.config.js`. The following options are available:
486486

487+
## `localPatterns`
488+
489+
You can optionally configure `localPatterns` in your `next.config.js` file in order to allow specific paths to be optimized and block all others paths.
490+
491+
```js filename="next.config.js"
492+
module.exports = {
493+
images: {
494+
localPatterns: [
495+
{
496+
pathname: '/assets/images/**',
497+
search: '',
498+
},
499+
],
500+
},
501+
}
502+
```
503+
504+
> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `/assets/images/` and must not have a query string. Attempting to optimize any other path will respond with 400 Bad Request.
505+
487506
### `remotePatterns`
488507

489508
To protect your application from malicious users, configuration is required in order to use external images. This ensures that only external images from your account can be served from the Next.js Image Optimization API. These external images can be configured with the `remotePatterns` property in your `next.config.js` file, as shown below:

errors/invalid-images-config.mdx

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ module.exports = {
3737
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
3838
// sets the Content-Disposition header (inline or attachment)
3939
contentDispositionType: 'inline',
40+
// limit of 25 objects
41+
localPatterns: [],
4042
// limit of 50 objects
4143
remotePatterns: [],
4244
// when true, every image will be unoptimized
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
title: '`next/image` Un-configured localPatterns'
3+
---
4+
5+
## Why This Error Occurred
6+
7+
One of your pages that leverages the `next/image` component, passed a `src` value that uses a URL that isn't defined in the `images.localPatterns` property in `next.config.js`.
8+
9+
## Possible Ways to Fix It
10+
11+
Add an entry to `images.localPatterns` array in `next.config.js` with the expected URL pattern. For example:
12+
13+
```js filename="next.config.js"
14+
module.exports = {
15+
images: {
16+
localPatterns: [
17+
{
18+
pathname: '/assets/**',
19+
search: '',
20+
},
21+
],
22+
},
23+
}
24+
```
25+
26+
## Useful Links
27+
28+
- [Image Optimization Documentation](/docs/pages/building-your-application/optimizing/images)
29+
- [Local Patterns Documentation](/docs/pages/api-reference/components/image#localpatterns)

packages/next/src/build/index.ts

+17-12
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,11 @@ async function writeImagesManifest(
420420
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
421421
search: p.search,
422422
}))
423+
images.localPatterns = (config?.images?.localPatterns || []).map((p) => ({
424+
// Modifying the manifest should also modify matchLocalPattern()
425+
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
426+
search: p.search,
427+
}))
423428

424429
await writeManifest(path.join(distDir, IMAGES_MANIFEST), {
425430
version: 1,
@@ -1885,18 +1890,18 @@ export default async function build(
18851890
config.experimental.gzipSize
18861891
)
18871892

1888-
const middlewareManifest: MiddlewareManifest = require(path.join(
1889-
distDir,
1890-
SERVER_DIRECTORY,
1891-
MIDDLEWARE_MANIFEST
1892-
))
1893+
const middlewareManifest: MiddlewareManifest = require(
1894+
path.join(distDir, SERVER_DIRECTORY, MIDDLEWARE_MANIFEST)
1895+
)
18931896

18941897
const actionManifest = appDir
1895-
? (require(path.join(
1896-
distDir,
1897-
SERVER_DIRECTORY,
1898-
SERVER_REFERENCE_MANIFEST + '.json'
1899-
)) as ActionManifest)
1898+
? (require(
1899+
path.join(
1900+
distDir,
1901+
SERVER_DIRECTORY,
1902+
SERVER_REFERENCE_MANIFEST + '.json'
1903+
)
1904+
) as ActionManifest)
19001905
: null
19011906
const entriesWithAction = actionManifest ? new Set() : null
19021907
if (actionManifest && entriesWithAction) {
@@ -3282,8 +3287,8 @@ export default async function build(
32823287
fallback: ssgBlockingFallbackPages.has(tbdRoute)
32833288
? null
32843289
: ssgStaticFallbackPages.has(tbdRoute)
3285-
? `${normalizedRoute}.html`
3286-
: false,
3290+
? `${normalizedRoute}.html`
3291+
: false,
32873292
dataRouteRegex: normalizeRouteRegex(
32883293
getNamedRouteRegex(
32893294
dataRoute.replace(/\.json$/, ''),

packages/next/src/build/webpack/plugins/define-env-plugin.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ function getImageConfig(
117117
// pass domains in development to allow validating on the client
118118
domains: config.images.domains,
119119
remotePatterns: config.images?.remotePatterns,
120+
localPatterns: config.images?.localPatterns,
120121
output: config.output,
121122
}
122123
: {}),
@@ -162,8 +163,8 @@ export function getDefineEnv({
162163
'process.env.NEXT_RUNTIME': isEdgeServer
163164
? 'edge'
164165
: isNodeServer
165-
? 'nodejs'
166-
: '',
166+
? 'nodejs'
167+
: '',
167168
'process.env.NEXT_MINIMAL': '',
168169
'process.env.__NEXT_PPR': config.experimental.ppr === true,
169170
'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,

packages/next/src/client/legacy/image.tsx

+23-2
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,25 @@ function defaultLoader({
139139
)
140140
}
141141

142+
if (src.startsWith('/') && config.localPatterns) {
143+
if (
144+
process.env.NODE_ENV !== 'test' &&
145+
// micromatch isn't compatible with edge runtime
146+
process.env.NEXT_RUNTIME !== 'edge'
147+
) {
148+
// We use dynamic require because this should only error in development
149+
const {
150+
hasLocalMatch,
151+
} = require('../../shared/lib/match-local-pattern')
152+
if (!hasLocalMatch(config.localPatterns, src)) {
153+
throw new Error(
154+
`Invalid src prop (${src}) on \`next/image\` does not match \`images.localPatterns\` configured in your \`next.config.js\`\n` +
155+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns`
156+
)
157+
}
158+
}
159+
}
160+
142161
if (!src.startsWith('/') && (config.domains || config.remotePatterns)) {
143162
let parsedSrc: URL
144163
try {
@@ -156,8 +175,10 @@ function defaultLoader({
156175
process.env.NEXT_RUNTIME !== 'edge'
157176
) {
158177
// We use dynamic require because this should only error in development
159-
const { hasMatch } = require('../../shared/lib/match-remote-pattern')
160-
if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) {
178+
const {
179+
hasRemoteMatch,
180+
} = require('../../shared/lib/match-remote-pattern')
181+
if (!hasRemoteMatch(config.domains, config.remotePatterns, parsedSrc)) {
161182
throw new Error(
162183
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
163184
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`

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

+9
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,15 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
461461
.optional(),
462462
images: z
463463
.strictObject({
464+
localPatterns: z
465+
.array(
466+
z.strictObject({
467+
pathname: z.string().optional(),
468+
search: z.string().optional(),
469+
})
470+
)
471+
.max(25)
472+
.optional(),
464473
remotePatterns: z
465474
.array(
466475
z.strictObject({

packages/next/src/server/config.ts

+13
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,19 @@ function assignDefaults(
368368
)
369369
}
370370

371+
if (images.localPatterns) {
372+
if (!Array.isArray(images.localPatterns)) {
373+
throw new Error(
374+
`Specified images.localPatterns should be an Array received ${typeof images.localPatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
375+
)
376+
}
377+
// static import images are automatically allowed
378+
images.localPatterns.push({
379+
pathname: '/_next/static/media/**',
380+
search: '',
381+
})
382+
}
383+
371384
if (images.remotePatterns) {
372385
if (!Array.isArray(images.remotePatterns)) {
373386
throw new Error(

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

+7-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import nodeUrl, { type UrlWithParsedQuery } from 'url'
1212

1313
import { getImageBlurSvg } from '../shared/lib/image-blur-svg'
1414
import type { ImageConfigComplete } from '../shared/lib/image-config'
15-
import { hasMatch } from '../shared/lib/match-remote-pattern'
15+
import { hasLocalMatch } from '../shared/lib/match-local-pattern'
16+
import { hasRemoteMatch } from '../shared/lib/match-remote-pattern'
1617
import type { NextConfigComplete } from './config-shared'
1718
import { createRequestResponseMocks } from './lib/mock-request'
1819
// Do not import anything other than types from this module
@@ -173,6 +174,7 @@ export class ImageOptimizerCache {
173174
formats = ['image/webp'],
174175
} = imageData
175176
const remotePatterns = nextConfig.images?.remotePatterns || []
177+
const localPatterns = nextConfig.images?.localPatterns
176178
const { url, w, q } = query
177179
let href: string
178180

@@ -212,6 +214,9 @@ export class ImageOptimizerCache {
212214
errorMessage: '"url" parameter cannot be recursive',
213215
}
214216
}
217+
if (!hasLocalMatch(localPatterns, url)) {
218+
return { errorMessage: '"url" parameter is not allowed' }
219+
}
215220
} else {
216221
let hrefParsed: URL
217222

@@ -227,7 +232,7 @@ export class ImageOptimizerCache {
227232
return { errorMessage: '"url" parameter is invalid' }
228233
}
229234

230-
if (!hasMatch(domains, remotePatterns, hrefParsed)) {
235+
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
231236
return { errorMessage: '"url" parameter is not allowed' }
232237
}
233238
}

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

+20-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ export type ImageLoaderPropsWithConfig = ImageLoaderProps & {
1818
config: Readonly<ImageConfig>
1919
}
2020

21+
export type LocalPattern = {
22+
/**
23+
* Can be literal or wildcard.
24+
* Single `*` matches a single path segment.
25+
* Double `**` matches any number of path segments.
26+
*/
27+
pathname?: string
28+
29+
/**
30+
* Can be literal query string such as `?v=1` or
31+
* empty string meaning no query string.
32+
*/
33+
search?: string
34+
}
35+
2136
export type RemotePattern = {
2237
/**
2338
* Must be `http` or `https`.
@@ -100,6 +115,9 @@ export type ImageConfigComplete = {
100115
/** @see [Remote Patterns](https://nextjs.org/docs/api-reference/next/image#remotepatterns) */
101116
remotePatterns: RemotePattern[]
102117

118+
/** @see [Remote Patterns](https://nextjs.org/docs/api-reference/next/image#localPatterns) */
119+
localPatterns: LocalPattern[] | undefined
120+
103121
/** @see [Unoptimized](https://nextjs.org/docs/api-reference/next/image#unoptimized) */
104122
unoptimized: boolean
105123
}
@@ -119,6 +137,7 @@ export const imageConfigDefault: ImageConfigComplete = {
119137
dangerouslyAllowSVG: false,
120138
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
121139
contentDispositionType: 'inline',
122-
remotePatterns: [],
140+
localPatterns: undefined, // default: allow all local images
141+
remotePatterns: [], // default: allow no remote images
123142
unoptimized: false,
124143
}

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

+19-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ function defaultLoader({
2929
)
3030
}
3131

32+
if (src.startsWith('/') && config.localPatterns) {
33+
if (
34+
process.env.NODE_ENV !== 'test' &&
35+
// micromatch isn't compatible with edge runtime
36+
process.env.NEXT_RUNTIME !== 'edge'
37+
) {
38+
// We use dynamic require because this should only error in development
39+
const { hasLocalMatch } = require('./match-local-pattern')
40+
if (!hasLocalMatch(config.localPatterns, src)) {
41+
throw new Error(
42+
`Invalid src prop (${src}) on \`next/image\` does not match \`images.localPatterns\` configured in your \`next.config.js\`\n` +
43+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns`
44+
)
45+
}
46+
}
47+
}
48+
3249
if (!src.startsWith('/') && (config.domains || config.remotePatterns)) {
3350
let parsedSrc: URL
3451
try {
@@ -46,8 +63,8 @@ function defaultLoader({
4663
process.env.NEXT_RUNTIME !== 'edge'
4764
) {
4865
// We use dynamic require because this should only error in development
49-
const { hasMatch } = require('./match-remote-pattern')
50-
if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) {
66+
const { hasRemoteMatch } = require('./match-remote-pattern')
67+
if (!hasRemoteMatch(config.domains, config.remotePatterns, parsedSrc)) {
5168
throw new Error(
5269
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
5370
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { LocalPattern } from './image-config'
2+
import { makeRe } from 'next/dist/compiled/picomatch'
3+
4+
// Modifying this function should also modify writeImagesManifest()
5+
export function matchLocalPattern(pattern: LocalPattern, url: URL): boolean {
6+
if (pattern.search !== undefined) {
7+
if (pattern.search !== url.search) {
8+
return false
9+
}
10+
}
11+
12+
if (!makeRe(pattern.pathname ?? '**', { dot: true }).test(url.pathname)) {
13+
return false
14+
}
15+
16+
return true
17+
}
18+
19+
export function hasLocalMatch(
20+
localPatterns: LocalPattern[] | undefined,
21+
urlPathAndQuery: string
22+
): boolean {
23+
if (!localPatterns) {
24+
// if the user didn't define "localPatterns", we allow all local images
25+
return true
26+
}
27+
const url = new URL(urlPathAndQuery, 'http://n')
28+
return localPatterns.some((p) => matchLocalPattern(p, url))
29+
}

packages/next/src/shared/lib/match-remote-pattern.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
3939
return true
4040
}
4141

42-
export function hasMatch(
42+
export function hasRemoteMatch(
4343
domains: string[],
4444
remotePatterns: RemotePattern[],
4545
url: URL

0 commit comments

Comments
 (0)