From 52a157bffdb3f8c4f42a34dab21061ebddd7a2d3 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 20 Sep 2024 18:27:02 -0400 Subject: [PATCH 1/3] feat(next/image): add support for `remotePatterns.search` --- .../02-api-reference/01-components/image.mdx | 8 +- .../01-components/image-legacy.mdx | 8 +- 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, 114 insertions(+), 7 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 cd1af92a62773..1e7b5538a219d 100644 --- a/docs/02-app/02-api-reference/01-components/image.mdx +++ b/docs/02-app/02-api-reference/01-components/image.mdx @@ -503,13 +503,14 @@ 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: @@ -521,13 +522,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: @@ -536,7 +538,7 @@ 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. ### `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 6d0ccff0337b0..e53bf5bba780a 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,13 +362,14 @@ 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: @@ -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,7 @@ 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. ### Domains diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 766df924bd9ee..d581081840241 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -501,11 +501,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 54af2ef4c1c59..4efe8074d047f 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -507,6 +507,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(), }) ) .max(50) diff --git a/packages/next/src/shared/lib/image-config.ts b/packages/next/src/shared/lib/image-config.ts index 4fc8c5ab1971c..1510a1f1b3427 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) From e59c02026647d787e432c7c0be0a15b7d03d0b48 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 23 Sep 2024 12:58:39 -0400 Subject: [PATCH 2/3] fix: make search config optional --- packages/next/src/server/config-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 4efe8074d047f..82eecea40a2c3 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -507,7 +507,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(), + search: z.string().optional(), }) ) .max(50) From 3f999ca537b24314d27be9ed156bda74a271cb2a Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 23 Sep 2024 13:08:32 -0400 Subject: [PATCH 3/3] update docs with example --- .../02-api-reference/01-components/image.mdx | 20 ++++++++++++++++++- .../01-components/image-legacy.mdx | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 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 1e7b5538a219d..759cbf736dc83 100644 --- a/docs/02-app/02-api-reference/01-components/image.mdx +++ b/docs/02-app/02-api-reference/01-components/image.mdx @@ -512,7 +512,7 @@ module.exports = { > **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 = { @@ -540,6 +540,24 @@ The `**` syntax does not work in the middle of the pattern. > **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` > **Warning**: Deprecated since Next.js 14 in favor of strict [`remotePatterns`](#remotepatterns) in order to protect your application from malicious users. Only use `domains` if you own all the content served from the domain. 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 e53bf5bba780a..27afc13c54133 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 @@ -371,7 +371,7 @@ module.exports = { > **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 = { @@ -399,6 +399,24 @@ The `**` syntax does not work in the middle of the pattern. > **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 > **Warning**: Deprecated since Next.js 14 in favor of strict [`remotePatterns`](#remote-patterns) in order to protect your application from malicious users. Only use `domains` if you own all the content served from the domain.