Skip to content

Commit a9c045d

Browse files
authored
feat(next/image): add images.localPatterns config (#70529)
This adds support for `images.localPatterns` config to allow specific local images to be optimized and (more importantly) block anything that doesn't match a pattern.
1 parent f5d2f4c commit a9c045d

File tree

33 files changed

+584
-9
lines changed

33 files changed

+584
-9
lines changed

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

Lines changed: 15 additions & 0 deletions
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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,25 @@ Other properties on the `<Image />` component will be passed to the underlying
490490

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

493+
## `localPatterns`
494+
495+
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.
496+
497+
```js filename="next.config.js"
498+
module.exports = {
499+
images: {
500+
localPatterns: [
501+
{
502+
pathname: '/assets/images/**',
503+
search: '',
504+
},
505+
],
506+
},
507+
}
508+
```
509+
510+
> **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.
511+
493512
### `remotePatterns`
494513

495514
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

Lines changed: 2 additions & 0 deletions
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: 'attachment',
40+
// limit of 25 objects
41+
localPatterns: [],
4042
// limit of 50 objects
4143
remotePatterns: [],
4244
// when true, every image will be unoptimized
Lines changed: 29 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,11 @@ async function writeImagesManifest(
509509
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
510510
search: p.search,
511511
}))
512+
images.localPatterns = (config?.images?.localPatterns || []).map((p) => ({
513+
// Modifying the manifest should also modify matchLocalPattern()
514+
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
515+
search: p.search,
516+
}))
512517

513518
await writeManifest(path.join(distDir, IMAGES_MANIFEST), {
514519
version: 1,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function getImageConfig(
118118
// pass domains in development to allow validating on the client
119119
domains: config.images.domains,
120120
remotePatterns: config.images?.remotePatterns,
121+
localPatterns: config.images?.localPatterns,
121122
output: config.output,
122123
}
123124
: {}),

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,25 @@ function defaultLoader({
143143
)
144144
}
145145

146+
if (src.startsWith('/') && config.localPatterns) {
147+
if (
148+
process.env.NODE_ENV !== 'test' &&
149+
// micromatch isn't compatible with edge runtime
150+
process.env.NEXT_RUNTIME !== 'edge'
151+
) {
152+
// We use dynamic require because this should only error in development
153+
const {
154+
hasLocalMatch,
155+
} = require('../../shared/lib/match-local-pattern')
156+
if (!hasLocalMatch(config.localPatterns, src)) {
157+
throw new Error(
158+
`Invalid src prop (${src}) on \`next/image\` does not match \`images.localPatterns\` configured in your \`next.config.js\`\n` +
159+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns`
160+
)
161+
}
162+
}
163+
}
164+
146165
if (!src.startsWith('/') && (config.domains || config.remotePatterns)) {
147166
let parsedSrc: URL
148167
try {
@@ -160,8 +179,10 @@ function defaultLoader({
160179
process.env.NEXT_RUNTIME !== 'edge'
161180
) {
162181
// We use dynamic require because this should only error in development
163-
const { hasMatch } = require('../../shared/lib/match-remote-pattern')
164-
if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) {
182+
const {
183+
hasRemoteMatch,
184+
} = require('../../shared/lib/match-remote-pattern')
185+
if (!hasRemoteMatch(config.domains, config.remotePatterns, parsedSrc)) {
165186
throw new Error(
166187
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
167188
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,15 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
500500
.optional(),
501501
images: z
502502
.strictObject({
503+
localPatterns: z
504+
.array(
505+
z.strictObject({
506+
pathname: z.string().optional(),
507+
search: z.string().optional(),
508+
})
509+
)
510+
.max(25)
511+
.optional(),
503512
remotePatterns: z
504513
.array(
505514
z.strictObject({

packages/next/src/server/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,19 @@ function assignDefaults(
327327
)
328328
}
329329

330+
if (images.localPatterns) {
331+
if (!Array.isArray(images.localPatterns)) {
332+
throw new Error(
333+
`Specified images.localPatterns should be an Array received ${typeof images.localPatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
334+
)
335+
}
336+
// static import images are automatically allowed
337+
images.localPatterns.push({
338+
pathname: '/_next/static/media/**',
339+
search: '',
340+
})
341+
}
342+
330343
if (images.remotePatterns) {
331344
if (!Array.isArray(images.remotePatterns)) {
332345
throw new Error(

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import nodeUrl, { type UrlWithParsedQuery } from 'url'
1010

1111
import { getImageBlurSvg } from '../shared/lib/image-blur-svg'
1212
import type { ImageConfigComplete } from '../shared/lib/image-config'
13-
import { hasMatch } from '../shared/lib/match-remote-pattern'
13+
import { hasLocalMatch } from '../shared/lib/match-local-pattern'
14+
import { hasRemoteMatch } from '../shared/lib/match-remote-pattern'
1415
import type { NextConfigComplete } from './config-shared'
1516
import { createRequestResponseMocks } from './lib/mock-request'
1617
import type { NextUrlWithParsedQuery } from './request-meta'
@@ -213,6 +214,7 @@ export class ImageOptimizerCache {
213214
formats = ['image/webp'],
214215
} = imageData
215216
const remotePatterns = nextConfig.images?.remotePatterns || []
217+
const localPatterns = nextConfig.images?.localPatterns
216218
const { url, w, q } = query
217219
let href: string
218220

@@ -252,6 +254,9 @@ export class ImageOptimizerCache {
252254
errorMessage: '"url" parameter cannot be recursive',
253255
}
254256
}
257+
if (!hasLocalMatch(localPatterns, url)) {
258+
return { errorMessage: '"url" parameter is not allowed' }
259+
}
255260
} else {
256261
let hrefParsed: URL
257262

@@ -267,7 +272,7 @@ export class ImageOptimizerCache {
267272
return { errorMessage: '"url" parameter is invalid' }
268273
}
269274

270-
if (!hasMatch(domains, remotePatterns, hrefParsed)) {
275+
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
271276
return { errorMessage: '"url" parameter is not allowed' }
272277
}
273278
}

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

Lines changed: 20 additions & 1 deletion
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: 'attachment',
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

Lines changed: 19 additions & 2 deletions
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`
Lines changed: 29 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

packages/next/src/telemetry/events/version.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type EventCliSessionStarted = {
2323
localeDetectionEnabled: boolean | null
2424
imageDomainsCount: number | null
2525
imageRemotePatternsCount: number | null
26+
imageLocalPatternsCount: number | null
2627
imageSizes: string | null
2728
imageLoader: string | null
2829
imageFormats: string | null
@@ -78,6 +79,7 @@ export function eventCliSession(
7879
| 'localeDetectionEnabled'
7980
| 'imageDomainsCount'
8081
| 'imageRemotePatternsCount'
82+
| 'imageLocalPatternsCount'
8183
| 'imageSizes'
8284
| 'imageLoader'
8385
| 'imageFormats'
@@ -120,6 +122,9 @@ export function eventCliSession(
120122
imageRemotePatternsCount: images?.remotePatterns
121123
? images.remotePatterns.length
122124
: null,
125+
imageLocalPatternsCount: images?.localPatterns
126+
? images.localPatterns.length
127+
: null,
123128
imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null,
124129
imageLoader: images?.loader,
125130
imageFormats: images?.formats ? images.formats.join(',') : null,

0 commit comments

Comments
 (0)