Skip to content

Commit 2d168b6

Browse files
committed
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 f1fc357 commit 2d168b6

File tree

33 files changed

+596
-21
lines changed

33 files changed

+596
-21
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
@@ -400,6 +400,25 @@ Other properties on the `<Image />` component will be passed to the underlying
400400

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

403+
## `localPatterns`
404+
405+
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.
406+
407+
```js filename="next.config.js"
408+
module.exports = {
409+
images: {
410+
localPatterns: [
411+
{
412+
pathname: '/assets/images/**',
413+
search: '',
414+
},
415+
],
416+
},
417+
}
418+
```
419+
420+
> **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.
421+
403422
### `remotePatterns`
404423

405424
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: 'inline',
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: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ async function writeImagesManifest(
444444
port: p.port,
445445
pathname: makeRe(p.pathname ?? '**').source,
446446
}))
447+
images.localPatterns = (config?.images?.localPatterns || []).map((p) => ({
448+
// Modifying the manifest should also modify matchLocalPattern()
449+
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
450+
search: p.search,
451+
}))
447452

448453
await writeManifest(path.join(distDir, IMAGES_MANIFEST), {
449454
version: 1,
@@ -1653,18 +1658,18 @@ export default async function build(
16531658
config.experimental.gzipSize
16541659
)
16551660

1656-
const middlewareManifest: MiddlewareManifest = require(path.join(
1657-
distDir,
1658-
SERVER_DIRECTORY,
1659-
MIDDLEWARE_MANIFEST
1660-
))
1661+
const middlewareManifest: MiddlewareManifest = require(
1662+
path.join(distDir, SERVER_DIRECTORY, MIDDLEWARE_MANIFEST)
1663+
)
16611664

16621665
const actionManifest = appDir
1663-
? (require(path.join(
1664-
distDir,
1665-
SERVER_DIRECTORY,
1666-
SERVER_REFERENCE_MANIFEST + '.json'
1667-
)) as ActionManifest)
1666+
? (require(
1667+
path.join(
1668+
distDir,
1669+
SERVER_DIRECTORY,
1670+
SERVER_REFERENCE_MANIFEST + '.json'
1671+
)
1672+
) as ActionManifest)
16681673
: null
16691674
const entriesWithAction = actionManifest ? new Set() : null
16701675
if (actionManifest && entriesWithAction) {
@@ -3023,8 +3028,8 @@ export default async function build(
30233028
fallback: ssgBlockingFallbackPages.has(tbdRoute)
30243029
? null
30253030
: ssgStaticFallbackPages.has(tbdRoute)
3026-
? `${normalizedRoute}.html`
3027-
: false,
3031+
? `${normalizedRoute}.html`
3032+
: false,
30283033
dataRouteRegex: normalizeRouteRegex(
30293034
getNamedRouteRegex(
30303035
dataRoute.replace(/\.json$/, ''),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function getDefineEnv({
175175
// pass domains in development to allow validating on the client
176176
domains: config.images.domains,
177177
remotePatterns: config.images?.remotePatterns,
178+
localPatterns: config.images?.localPatterns,
178179
output: config.output,
179180
}
180181
: {}),

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

Lines changed: 23 additions & 2 deletions
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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,15 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
425425
.optional(),
426426
images: z
427427
.strictObject({
428+
localPatterns: z
429+
.array(
430+
z.strictObject({
431+
pathname: z.string().optional(),
432+
search: z.string().optional(),
433+
})
434+
)
435+
.max(25)
436+
.optional(),
428437
remotePatterns: z
429438
.array(
430439
z.strictObject({

packages/next/src/server/config.ts

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

Lines changed: 7 additions & 2 deletions
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
@@ -172,6 +173,7 @@ export class ImageOptimizerCache {
172173
formats = ['image/webp'],
173174
} = imageData
174175
const remotePatterns = nextConfig.images?.remotePatterns || []
176+
const localPatterns = nextConfig.images?.localPatterns
175177
const { url, w, q } = query
176178
let href: string
177179

@@ -192,6 +194,9 @@ export class ImageOptimizerCache {
192194
if (url.startsWith('/')) {
193195
href = url
194196
isAbsolute = false
197+
if (!hasLocalMatch(localPatterns, url)) {
198+
return { errorMessage: '"url" parameter is not allowed' }
199+
}
195200
} else {
196201
let hrefParsed: URL
197202

@@ -207,7 +212,7 @@ export class ImageOptimizerCache {
207212
return { errorMessage: '"url" parameter is invalid' }
208213
}
209214

210-
if (!hasMatch(domains, remotePatterns, hrefParsed)) {
215+
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
211216
return { errorMessage: '"url" parameter is not allowed' }
212217
}
213218
}

0 commit comments

Comments
 (0)