From ab03c7749a0dd1351af8a51c0679b422d4e874a7 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 24 Sep 2024 12:12:58 -0400 Subject: [PATCH] feat(next/image): add support for `images.remotePatterns.search` (#70302) This PR adds a new feature to the existing `remotePatterns` allowlist configuration, that enables the ability to filter on `search` (aka query string). The most common usage will likely be `search: ''`, which means no query string allowed. You can also set the exact query string such as `search: '?v=1'` which can be useful to invalidate the cache one time for a specific version without opening the door to any version. Note the leading question mark to match the behavior of `new URL(url).search`. --- .../02-api-reference/01-components/image.mdx | 28 +++++- .../01-components/image-legacy.mdx | 28 +++++- packages/next/src/build/index.ts | 3 +- packages/next/src/server/config-schema.ts | 1 + packages/next/src/shared/lib/image-config.ts | 6 ++ .../src/shared/lib/match-remote-pattern.ts | 8 ++ .../match-remote-pattern.test.ts | 87 +++++++++++++++++++ 7 files changed, 152 insertions(+), 9 deletions(-) diff --git a/docs/02-app/02-api-reference/01-components/image.mdx b/docs/02-app/02-api-reference/01-components/image.mdx index 74a11d6fc106c..d6143397ced73 100644 --- a/docs/02-app/02-api-reference/01-components/image.mdx +++ b/docs/02-app/02-api-reference/01-components/image.mdx @@ -486,15 +486,16 @@ module.exports = { hostname: 'example.com', port: '', pathname: '/account123/**', + search: '', }, ], }, } ``` -> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://example.com/account123/`. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request. +> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://example.com/account123/` and must not have a query string. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request. -Below is another example of the `remotePatterns` property in the `next.config.js` file: +Below is an example of the `remotePatterns` property in the `next.config.js` file using a wildcard pattern in the `hostname`: ```js filename="next.config.js" module.exports = { @@ -504,13 +505,14 @@ module.exports = { protocol: 'https', hostname: '**.example.com', port: '', + search: '', }, ], }, } ``` -> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. Any other protocol, port, or unmatched hostname will respond with 400 Bad Request. +> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. It cannot have a port or query string. Any other protocol or unmatched hostname will respond with 400 Bad Request. Wildcard patterns can be used for both `pathname` and `hostname` and have the following syntax: @@ -519,7 +521,25 @@ Wildcard patterns can be used for both `pathname` and `hostname` and have the fo The `**` syntax does not work in the middle of the pattern. -> **Good to know**: When omitting `protocol`, `port` or `pathname`, then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend. +> **Good to know**: When omitting `protocol`, `port`, `pathname`, or `search` then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend. + +Below is an example of the `remotePatterns` property in the `next.config.js` file using `search`: + +```js filename="next.config.js" +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'assets.example.com', + search: '?v=1727111025337', + }, + ], + }, +} +``` + +> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://assets.example.com` and must have the exact query string `?v=1727111025337`. Any other protocol or query string will respond with 400 Bad Request. ### `domains` diff --git a/docs/03-pages/02-api-reference/01-components/image-legacy.mdx b/docs/03-pages/02-api-reference/01-components/image-legacy.mdx index b2b81a7d10c09..c4feae199b039 100644 --- a/docs/03-pages/02-api-reference/01-components/image-legacy.mdx +++ b/docs/03-pages/02-api-reference/01-components/image-legacy.mdx @@ -362,15 +362,16 @@ module.exports = { hostname: 'example.com', port: '', pathname: '/account123/**', + search: '', }, ], }, } ``` -> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://example.com/account123/`. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request. +> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://example.com/account123/` and must not have a query string. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request. -Below is another example of the `remotePatterns` property in the `next.config.js` file: +Below is an example of the `remotePatterns` property in the `next.config.js` file using a wildcard pattern in the `hostname`: ```js filename="next.config.js" module.exports = { @@ -380,13 +381,14 @@ module.exports = { protocol: 'https', hostname: '**.example.com', port: '', + search: '', }, ], }, } ``` -> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. Any other protocol, port, or unmatched hostname will respond with 400 Bad Request. +> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. It cannot have a port or query string. Any other protocol or unmatched hostname will respond with 400 Bad Request. Wildcard patterns can be used for both `pathname` and `hostname` and have the following syntax: @@ -395,7 +397,25 @@ Wildcard patterns can be used for both `pathname` and `hostname` and have the fo The `**` syntax does not work in the middle of the pattern. -> **Good to know**: When omitting `protocol`, `port` or `pathname`, then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend. +> **Good to know**: When omitting `protocol`, `port`, `pathname`, or `search` then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend. + +Below is an example of the `remotePatterns` property in the `next.config.js` file using `search`: + +```js filename="next.config.js" +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'assets.example.com', + search: '?v=1727111025337', + }, + ], + }, +} +``` + +> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://assets.example.com` and must have the exact query string `?v=1727111025337`. Any other protocol or query string will respond with 400 Bad Request. ### Domains diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 298e3510b0faa..6c09660781eb7 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -413,11 +413,12 @@ async function writeImagesManifest( const { deviceSizes, imageSizes } = images ;(images as any).sizes = [...deviceSizes, ...imageSizes] images.remotePatterns = (config?.images?.remotePatterns || []).map((p) => ({ - // Should be the same as matchRemotePattern() + // Modifying the manifest should also modify matchRemotePattern() protocol: p.protocol, hostname: makeRe(p.hostname).source, port: p.port, pathname: makeRe(p.pathname ?? '**', { dot: true }).source, + search: p.search, })) await writeManifest(path.join(distDir, IMAGES_MANIFEST), { diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index e7e258800a59b..c04422bc0a8e2 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -468,6 +468,7 @@ export const configSchema: zod.ZodType = z.lazy(() => pathname: z.string().optional(), port: z.string().max(5).optional(), protocol: z.enum(['http', 'https']).optional(), + search: z.string().optional(), }) ) .max(50) diff --git a/packages/next/src/shared/lib/image-config.ts b/packages/next/src/shared/lib/image-config.ts index 7fe1448619fcc..66095a84120ea 100644 --- a/packages/next/src/shared/lib/image-config.ts +++ b/packages/next/src/shared/lib/image-config.ts @@ -43,6 +43,12 @@ export type RemotePattern = { * Double `**` matches any number of path segments. */ pathname?: string + + /** + * Can be literal query string such as `?v=1` or + * empty string meaning no query string. + */ + search?: string } type ImageFormat = 'image/avif' | 'image/webp' diff --git a/packages/next/src/shared/lib/match-remote-pattern.ts b/packages/next/src/shared/lib/match-remote-pattern.ts index c4d81ac39d5ec..020f596006ffc 100644 --- a/packages/next/src/shared/lib/match-remote-pattern.ts +++ b/packages/next/src/shared/lib/match-remote-pattern.ts @@ -1,6 +1,7 @@ import type { RemotePattern } from './image-config' import { makeRe } from 'next/dist/compiled/picomatch' +// Modifying this function should also modify writeImagesManifest() export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { if (pattern.protocol !== undefined) { const actualProto = url.protocol.slice(0, -1) @@ -24,6 +25,13 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { } } + if (pattern.search !== undefined) { + if (pattern.search !== url.search) { + return false + } + } + + // Should be the same as writeImagesManifest() if (!makeRe(pattern.pathname ?? '**', { dot: true }).test(url.pathname)) { return false } diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index ea270dd1e8d3e..d8ddc674c4a2c 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -50,6 +50,26 @@ describe('matchRemotePattern', () => { expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(false) }) + it('should match literal protocol, hostname, no port, no search', () => { + const p = { + protocol: 'https', + hostname: 'example.com', + port: '', + search: '', + } as const + expect(m(p, new URL('https://example.com'))).toBe(true) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(false) + }) + it('should match literal protocol, hostname, port 42', () => { const p = { protocol: 'https', @@ -107,6 +127,73 @@ describe('matchRemotePattern', () => { expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(false) }) + it('should match literal protocol, hostname, port, pathname, search', () => { + const p = { + protocol: 'https', + hostname: 'example.com', + port: '42', + pathname: '/path/to/file', + search: '?q=1&a=two&s=!@$^&-_+/()[]{};:~', + } as const + expect(m(p, new URL('https://example.com:42'))).toBe(false) + expect(m(p, new URL('https://example.com.uk:42'))).toBe(false) + expect(m(p, new URL('https://sub.example.com:42'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to'))).toBe(false) + expect(m(p, new URL('https://example.com:42/file'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('http://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://example.com/path'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to/file?q=1&a=two'))).toBe( + false + ) + expect( + m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s')) + ).toBe(false) + expect( + m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s=')) + ).toBe(false) + expect( + m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s=!@')) + ).toBe(false) + expect( + m( + p, + new URL( + 'https://example.com:42/path/to/file?q=1&a=two&s=!@$^&-_+/()[]{};:~' + ) + ) + ).toBe(true) + expect( + m( + p, + new URL( + 'https://example.com:42/path/to/file?q=1&s=!@$^&-_+/()[]{};:~&a=two' + ) + ) + ).toBe(false) + expect( + m( + p, + new URL( + 'https://example.com:42/path/to/file?a=two&q=1&s=!@$^&-_+/()[]{};:~' + ) + ) + ).toBe(false) + }) + it('should match hostname pattern with single asterisk by itself', () => { const p = { hostname: 'avatars.*.example.com' } as const expect(m(p, new URL('https://com'))).toBe(false)