Skip to content

Commit

Permalink
feat(next/image): add support for images.remotePatterns.search (#70302
Browse files Browse the repository at this point in the history
)

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`.
  • Loading branch information
styfle authored and ijjk committed Sep 24, 2024
1 parent f550237 commit ab03c77
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 9 deletions.
28 changes: 24 additions & 4 deletions docs/02-app/02-api-reference/01-components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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:

Expand All @@ -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`

Expand Down
28 changes: 24 additions & 4 deletions docs/03-pages/02-api-reference/01-components/image-legacy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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:

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
pathname: z.string().optional(),
port: z.string().max(5).optional(),
protocol: z.enum(['http', 'https']).optional(),
search: z.string().optional(),
})
)
.max(50)
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/shared/lib/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions packages/next/src/shared/lib/match-remote-pattern.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
}
Expand Down
87 changes: 87 additions & 0 deletions test/unit/image-optimizer/match-remote-pattern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit ab03c77

Please sign in to comment.