Security context
Medium· 5.9GHSA-fmvm-x8mv-47mj CVE-2022-23646CWE-451Published Feb 17, 2022

Improper CSP in Image Optimization API for Next.js versions between 10.0.0 and 12.1.0

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

10.0.0 → fixed in 12.1.0

Details

Next.js is a React framework. Starting with version 10.0.0 and prior to version 12.1.0, Next.js is vulnerable to User Interface (UI) Misrepresentation of Critical Information. In order to be affected, the `next.config.js` file must have an `images.domains` array assigned and the image host assigned in `images.domains` must allow user-provided SVG. If the `next.config.js` file has `images.loader` assigned to something other than default, the instance is not affected. Version 12.1.0 contains a patch for this issue. As a workaround, change `next.config.js` to use a different `loader configuration` other than the default. ### Impact - **Affected**: All of the following must be true to be affected - Next.js between version 10.0.0 and 12.0.10 - The `next.config.js` file has [images.domains](https://nextjs.org/docs/api-reference/next/image#domains) array assigned - The image host assigned in [images.domains](https://nextjs.org/docs/api-reference/next/image#domains) allows user-provided SVG - **Not affected**: The `next.config.js` file has [images.loader](https://nextjs.org/docs/api-reference/next/image#loader-configuration) assigned to something other than default ### Patches [Next.js 12.1.0](https://github.com/vercel/next.js/releases/tag/v12.1.0) ### Workarounds Change `next.config.js` to use a different [loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) other than the default, for example: ```js module.exports = { images: { loader: 'imgix', path: 'https://example.com/myaccount/', }, } ``` Or if you want to use the [`loader`](https://nextjs.org/docs/api-reference/next/image#loader) prop on the component, you can use `custom`: ```js module.exports = { images: { loader: 'custom', }, } ```

The fix

Update to leverage response-cache for image-optimizer

JJ Kasper· Feb 7, 2022, 11:35 PM+527287d81a88b84b
packages/next/server/base-server.ts+10 0
@@ -58,6 +58,7 @@ import { MIDDLEWARE_ROUTE } from '../lib/constants'
import { addRequestMeta, getRequestMeta } from './request-meta'
import { createHeaderRoute, createRedirectRoute } from './server-route-utils'
import { PrerenderManifest } from '../build'
+import { ImageOptimizerCache } from './image-optimizer'
export type FindComponentsResult = {
components: LoadComponentsReturnType
@@ -165,6 +166,7 @@ export default abstract class Server {
}
private incrementalCache: IncrementalCache
private responseCache: ResponseCache
+ protected imageResponseCache: ResponseCache
protected router: Router
protected dynamicRoutes?: DynamicRoutes
protected customRoutes: CustomRoutes
@@ -360,6 +362,12 @@ export default abstract class Server {
},
})
this.responseCache = new ResponseCache(this.incrementalCache)
+ this.imageResponseCache = new ResponseCache(
+ new ImageOptimizerCache({
+ distDir: this.distDir,
+ nextConfig: this.nextConfig,
+ }) as any
+ )
}
public logError(err: Error): void {
@@ -1516,6 +1524,8 @@ export default abstract class Server {
await handleRedirect(cachedData.props)
return null
}
+ } else if (cachedData.kind === 'IMAGE') {
+ throw new Error('invariant SSG should not return an image cache value')
} else {
return {
type: isDataReq ? 'json' : 'html',
packages/next/server/config-shared.ts+1 1
@@ -8,7 +8,7 @@ import {
} from './image-config'
export type NextConfigComplete = Required<NextConfig> & {
- images: ImageConfigComplete
+ images: Required<ImageConfigComplete>
typescript: Required<TypeScriptConfig>
configOrigin?: string
configFile?: string
packages/next/server/image-optimizer.ts+180 166
@@ -1,6 +1,6 @@
import { mediaType } from 'next/dist/compiled/@hapi/accept'
import { createHash } from 'crypto'
-import { createReadStream, promises } from 'fs'
+import { promises } from 'fs'
import { getOrientation, Orientation } from 'next/dist/compiled/get-orientation'
import imageSizeOf from 'next/dist/compiled/image-size'
import { IncomingMessage, ServerResponse } from 'http'
@@ -10,9 +10,7 @@ import contentDisposition from 'next/dist/compiled/content-disposition'
import { join } from 'path'
import Stream from 'stream'
import nodeUrl, { UrlWithParsedQuery } from 'url'
-import { NextConfig } from './config-shared'
-import { fileExists } from '../lib/file-exists'
-import { ImageConfig, imageConfigDefault } from './image-config'
+import { NextConfigComplete } from './config-shared'
import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main'
import { sendEtagResponse } from './send-payload'
import { getContentType, getExtension } from './serve-static'
@@ -31,7 +29,6 @@ const CACHE_VERSION = 3
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
const VECTOR_TYPES = [SVG]
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
-const inflightRequests = new Map<string, Promise<void>>()
let sharp:
| ((
@@ -48,489 +45,494 @@ try {
let showSharpMissingWarning = process.env.NODE_ENV === 'production'
-export async function imageOptimizer(
- req: IncomingMessage,
- res: ServerResponse,
- parsedUrl: UrlWithParsedQuery,
- nextConfig: NextConfig,
- distDir: string,
- render404: () => Promise<void>,
- handleRequest: (
- newReq: IncomingMessage,
- newRes: ServerResponse,
- newParsedUrl?: NextUrlWithParsedQuery
- ) => Promise<void>,
- isDev = false
-) {
- const imageData: ImageConfig = nextConfig.images || imageConfigDefault
- const {
- deviceSizes = [],
- imageSizes = [],
- domains = [],
- loader,
- minimumCacheTTL = 60,
- formats = ['image/webp'],
- } = imageData
-
- if (loader !== 'default') {
- await render404()
- return { finished: true }
- }
+interface ParamsResult {
+ href: string
+ isAbsolute: boolean
+ isStatic: boolean
+ width: number
+ quality: number
+ mimeType: string
+ sizes: number[]
+ minimumCacheTTL: number
+}
- const { headers } = req
- const { url, w, q } = parsedUrl.query
- const mimeType = getSupportedMimeType(formats, headers.accept)
- let href: string
+export class ImageOptimizerCache {
+ private cacheDir: string
+ private nextConfig: NextConfigComplete
+
+ static validateParams(
+ req: IncomingMessage,
+ query: UrlWithParsedQuery['query'],
+ nextConfig: NextConfigComplete,
+ isDev: boolean
+ ): ParamsResult | { errorMessage: string } {
+ const imageConfig = nextConfig.images
+ const { url, w, q } = query
+ let href: string
+
+ if (!url) {
+ return { errorMessage: '"url" parameter is required' }
+ } else if (Array.isArray(url)) {
+ return { errorMessage: '"url" parameter cannot be an array' }
+ }
- if (!url) {
- res.statusCode = 400
- res.end('"url" parameter is required')
- return { finished: true }
- } else if (Array.isArray(url)) {
- res.statusCode = 400
- res.end('"url" parameter cannot be an array')
- return { finished: true }
- }
+ let isAbsolute: boolean
- let isAbsolute: boolean
+ if (url.startsWith('/')) {
+ href = url
+ isAbsolute = false
+ } else {
+ let hrefParsed: URL
- if (url.startsWith('/')) {
- href = url
- isAbsolute = false
- } else {
- let hrefParsed: URL
+ try {
+ hrefParsed = new URL(url)
+ href = hrefParsed.toString()
+ isAbsolute = true
+ } catch (_error) {
+ return { errorMessage: '"url" parameter is invalid' }
+ }
- try {
- hrefParsed = new URL(url)
- href = hrefParsed.toString()
- isAbsolute = true
- } catch (_error) {
- res.statusCode = 400
- res.end('"url" parameter is invalid')
- return { finished: true }
+ if (!['http:', 'https:'].includes(hrefParsed.protocol)) {
+ return { errorMessage: '"url" parameter is invalid' }
+ }
+
+ if (
+ !imageConfig.domains ||
+ !imageConfig.domains.includes(hrefParsed.hostname)
+ ) {
+ return { errorMessage: '"url" parameter is not allowed' }
+ }
}
- if (!['http:', 'https:'].includes(hrefParsed.protocol)) {
- res.statusCode = 400
- res.end('"url" parameter is invalid')
- return { finished: true }
+ if (!w) {
+ return { errorMessage: '"w" parameter (width) is required' }
+ } else if (Array.isArray(w)) {
+ return { errorMessage: '"w" parameter (width) cannot be an array' }
}
- if (!domains.includes(hrefParsed.hostname)) {
- res.statusCode = 400
- res.end('"url" parameter is not allowed')
- return { finished: true }
+ if (!q) {
+ return { errorMessage: '"q" parameter (quality) is required' }
+ } else if (Array.isArray(q)) {
+ return { errorMessage: '"q" parameter (quality) cannot be an array' }
}
- }
- if (!w) {
- res.statusCode = 400
- res.end('"w" parameter (width) is required')
- return { finished: true }
- } else if (Array.isArray(w)) {
- res.statusCode = 400
- res.end('"w" parameter (width) cannot be an array')
- return { finished: true }
- }
+ const width = parseInt(w, 10)
- if (!q) {
- res.statusCode = 400
- res.end('"q" parameter (quality) is required')
- return { finished: true }
- } else if (Array.isArray(q)) {
- res.statusCode = 400
- res.end('"q" parameter (quality) cannot be an array')
- return { finished: true }
- }
+ if (!width || isNaN(width)) {
+ return {
+ errorMessage: '"w" parameter (width) must be a number greater than 0',
+ }
+ }
- // Should match output from next-image-loader
- const isStatic = url.startsWith(
- `${nextConfig.basePath || ''}/_next/static/media`
- )
+ const sizes = [
+ ...(imageConfig.deviceSizes || []),
+ ...(imageConfig.imageSizes || []),
+ ]
- const width = parseInt(w, 10)
+ if (isDev) {
+ sizes.push(BLUR_IMG_SIZE)
+ }
- if (!width || isNaN(width)) {
- res.statusCode = 400
- res.end('"w" parameter (width) must be a number greater than 0')
- return { finished: true }
- }
+ if (!sizes.includes(width)) {
+ return {
+ errorMessage: `"w" parameter (width) of ${width} is not allowed`,
+ }
+ }
- const sizes = [...deviceSizes, ...imageSizes]
+ const quality = parseInt(q)
- if (isDev) {
- sizes.push(BLUR_IMG_SIZE)
- }
+ if (isNaN(quality) || quality < 1 || quality > 100) {
+ return {
+ errorMessage:
+ '"q" parameter (quality) must be a number between 1 and 100',
+ }
+ }
- if (!sizes.includes(width)) {
- res.statusCode = 400
- res.end(`"w" parameter (width) of ${width} is not allowed`)
- return { finished: true }
- }
+ const mimeType = getSupportedMimeType(
+ imageConfig.formats || [],
+ req.headers['accept']
+ )
- const quality = parseInt(q)
+ const isStatic = url.startsWith(
+ `${nextConfig.basePath || ''}/_next/static/media`
+ )
- if (isNaN(quality) || quality < 1 || quality > 100) {
- res.statusCode = 400
- res.end('"q" parameter (quality) must be a number between 1 and 100')
- return { finished: true }
+ return {
+ href,
+ sizes,
+ isAbsolute,
+ isStatic,
+ width,
+ quality,
+ mimeType,
+ minimumCacheTTL: imageConfig.minimumCacheTTL,
+ }
}
- const hash = getHash([CACHE_VERSION, href, width, quality, mimeType])
- const imagesDir = join(distDir, 'cache', 'images')
- const hashDir = join(imagesDir, hash)
- const now = Date.now()
- let xCache: XCacheHeader = 'MISS'
+ static getCacheKey({
+ href,
+ width,
+ quality,
+ mimeType,
+ }: {
+ href: string
+ width: number
+ quality: number
+ mimeType: string
+ }): string {
+ return getHash([CACHE_VERSION, href, width, quality, mimeType])
+ }
- // If there're concurrent requests hitting the same resource and it's still
- // being optimized, wait before accessing the cache.
- if (inflightRequests.has(hash)) {
- await inflightRequests.get(hash)
+ constructor({
+ distDir,
+ nextConfig,
+ }: {
+ distDir: string
+ nextConfig: NextConfigComplete
+ }) {
+ this.cacheDir = join(distDir, 'cache', 'images')
+ this.nextConfig = nextConfig
}
- const dedupe = new Deferred<void>()
- inflightRequests.set(hash, dedupe.promise)
- try {
- if (await fileExists(hashDir, 'directory')) {
- const files = await promises.readdir(hashDir)
- for (let file of files) {
- const [maxAgeStr, expireAtSt, etag, extension] = file.split('.')
- const maxAge = Number(maxAgeStr)
+ async get(cacheKey: string) {
+ try {
+ const cacheDir = join(this.cacheDir, cacheKey)
+ const files = await promises.readdir(cacheDir)
+ const now = Date.now()
+
+ for (const file of files) {
+ const [maxAgeSt, expireAtSt, etag, extension] = file.split('.')
+ const buffer = await promises.readFile(join(cacheDir, file))
const expireAt = Number(expireAtSt)
- const contentType = getContentType(extension)
- const fsPath = join(hashDir, file)
- xCache = now < expireAt ? 'HIT' : 'STALE'
- const result = setResponseHeaders(
- req,
- res,
- url,
- etag,
- maxAge,
- contentType,
- isStatic,
- isDev,
- xCache
- )
- if (!result.finished) {
- await new Promise<void>((resolve, reject) => {
- createReadStream(fsPath)
- .on('end', resolve)
- .on('error', reject)
- .pipe(res)
- })
- }
- if (xCache === 'HIT') {
- return { finished: true }
- } else {
- await promises.unlink(fsPath)
+ const maxAge = Number(maxAgeSt)
+ const revalidate = maxAge
+
+ return {
+ value: {
+ kind: 'IMAGE',
+ etag,
+ buffer,
+ extension,
+ revalidate,
+ },
+ revalidate,
+ isStale: now > expireAt,
}
}
+ } catch (_) {
+ // failed to read from cache dir, treat as cache miss
}
+ return null
+ }
+ async set(
+ cacheKey: string,
+ value: {
+ etag: string
+ buffer: Buffer
+ extension: string
+ },
+ revalidate: number
+ ) {
+ const expireAt =
+ Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 +
+ Date.now()
+
+ await writeToCacheDir(
+ join(this.cacheDir, cacheKey),
+ value.extension,
+ revalidate,
+ expireAt,
+ value.buffer,
+ value.etag
+ )
+ }
+}
+export class ImageError extends Error {
+ statusCode?: number
- let upstreamBuffer: Buffer
- let upstreamType: string | null
- let maxAge: number
-
- if (isAbsolute) {
- const upstreamRes = await fetch(href)
-
- if (!upstreamRes.ok) {
- res.statusCode = upstreamRes.status
- res.end('"url" parameter is valid but upstream response is invalid')
- return { finished: true }
- }
+ constructor(message: string, statusCode?: number) {
+ super(message)
+ this.statusCode = statusCode
… diff truncated
packages/next/server/incremental-cache.ts+9 7
@@ -4,23 +4,24 @@ import LRUCache from 'next/dist/compiled/lru-cache'
import path from 'path'
import { PrerenderManifest } from '../build'
import { normalizePagePath } from './normalize-page-path'
+import { CachedImageValue, CachedRedirectValue } from './response-cache'
function toRoute(pathname: string): string {
return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/'
}
-interface CachedRedirectValue {
- kind: 'REDIRECT'
- props: Object
-}
-
interface CachedPageValue {
kind: 'PAGE'
+ // this needs to be a string since the cache expects to store
+ // the string value
html: string
pageData: Object
}
-export type IncrementalCacheValue = CachedRedirectValue | CachedPageValue
+export type IncrementalCacheValue =
+ | CachedRedirectValue
+ | CachedPageValue
+ | CachedImageValue
type IncrementalCacheEntry = {
curRevalidate?: number | false
@@ -82,7 +83,8 @@ export class IncrementalCache {
this.cache = new LRUCache({
max,
length({ value }) {
- if (!value || value.kind === 'REDIRECT') return 25
+ if (!value || value.kind === 'REDIRECT' || value.kind === 'IMAGE')
+ return 25
// rough estimate of size of cache value
return value.html.length + JSON.stringify(value.pageData).length
},
packages/next/server/next-server.ts+83 13
@@ -47,7 +47,7 @@ import {
NodeNextResponse,
} from './base-http'
import { PayloadOptions, sendRenderResult } from './send-payload'
-import { serveStatic } from './serve-static'
+import { getExtension, serveStatic } from './serve-static'
import { ParsedUrlQuery } from 'querystring'
import { apiResolver } from './api-utils'
import { RenderOpts, renderToHTML } from './render'
@@ -75,6 +75,14 @@ import { MIDDLEWARE_ROUTE } from '../lib/constants'
import { loadEnvConfig } from '@next/env'
import { getCustomRoute } from './server-route-utils'
import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring'
+import {
+ getHash,
+ ImageError,
+ ImageOptimizerCache,
+ sendResponse,
+ setResponseHeaders,
+} from './image-optimizer'
+import { CachedImageValue } from './response-cache'
export * from './base-server'
@@ -161,7 +169,7 @@ export default class NextNodeServer extends BaseServer {
match: route('/_next/image'),
type: 'route',
name: '_next/image catchall',
- fn: (req, res, _params, parsedUrl) => {
+ fn: async (req, res, _params, parsedUrl) => {
if (this.minimalMode) {
res.statusCode = 400
res.body('Bad Request').send()
@@ -169,12 +177,77 @@ export default class NextNodeServer extends BaseServer {
finished: true,
}
}
+ const imagesConfig = this.nextConfig.images
- return this.imageOptimizer(
- req as NodeNextRequest,
- res as NodeNextResponse,
- parsedUrl
+ if (imagesConfig.loader !== 'default') {
+ await this.render404(req, res)
+ return { finished: true }
+ }
+ const paramsResult = ImageOptimizerCache.validateParams(
+ req as any,
+ parsedUrl.query,
+ this.nextConfig,
+ !!this.renderOpts.dev
)
+
+ if ('errorMessage' in paramsResult) {
+ res.statusCode = 400
+ res.body(paramsResult.errorMessage).send()
+ return { finished: true }
+ }
+ const cacheKey = ImageOptimizerCache.getCacheKey(paramsResult)
+
+ try {
+ const cacheEntry = await this.imageResponseCache.get(
+ cacheKey,
+ async () => {
+ const { buffer, contentType, maxAge } =
+ await this.imageOptimizer(
+ req as NodeNextRequest,
+ res as NodeNextResponse,
+ paramsResult
+ )
+ const etag = getHash([buffer])
+
+ return {
+ value: {
+ kind: 'IMAGE',
+ buffer,
+ etag,
+ extension: getExtension(contentType) as string,
+ revalidate: maxAge,
+ },
+ revalidate: maxAge,
+ }
+ }
+ )
+
+ if (cacheEntry?.value?.kind !== 'IMAGE') {
+ throw new Error(
+ 'invariant did not get entry from image response cache'
+ )
+ }
+
+ sendResponse(
+ (req as NodeNextRequest).originalRequest,
+ (res as NodeNextResponse).originalResponse,
+ paramsResult.href,
+ cacheEntry.value.extension,
+ cacheEntry.value.buffer,
+ paramsResult.isStatic,
+ cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT'
+ )
+ } catch (err) {
+ if (err instanceof ImageError) {
+ res.statusCode = err.statusCode
+ res.body(err.message).send()
+ return {
+ finished: true,
+ }
+ }
+ throw err
+ }
+ return { finished: true }
},
},
]
@@ -482,25 +555,22 @@ export default class NextNodeServer extends BaseServer {
protected async imageOptimizer(
req: NodeNextRequest,
res: NodeNextResponse,
- parsedUrl: UrlWithParsedQuery
- ): Promise<{ finished: boolean }> {
+ paramsResult: any
+ ): Promise<{ buffer: Buffer; contentType: string; maxAge: number }> {
const { imageOptimizer } =
require('./image-optimizer') as typeof import('./image-optimizer')
return imageOptimizer(
req.originalRequest,
res.originalResponse,
- parsedUrl,
+ paramsResult,
this.nextConfig,
- this.distDir,
- () => this.render404(req, res, parsedUrl),
(newReq, newRes, newParsedUrl) =>
this.getRequestHandler()(
new NodeNextRequest(newReq),
new NodeNextResponse(newRes),
newParsedUrl
- ),
- this.renderOpts.dev
+ )
)
}
packages/next/server/response-cache.ts+24 3
@@ -1,22 +1,39 @@
import { IncrementalCache } from './incremental-cache'
import RenderResult from './render-result'
-interface CachedRedirectValue {
+export interface CachedRedirectValue {
kind: 'REDIRECT'
props: Object
}
interface CachedPageValue {
kind: 'PAGE'
+ // this needs to be a RenderResult so since renderResponse
+ // expects that type instead of a string
html: RenderResult
pageData: Object
}
-export type ResponseCacheValue = CachedRedirectValue | CachedPageValue
+export interface CachedImageValue {
+ kind: 'IMAGE'
+ etag: string
+ buffer: Buffer
+ extension: string
+ isMiss?: boolean
+ isStale?: boolean
+ revalidate: number
+}
+
+export type ResponseCacheValue =
+ | CachedRedirectValue
+ | CachedPageValue
+ | CachedImageValue
export type ResponseCacheEntry = {
revalidate?: number | false
value: ResponseCacheValue | null
+ isStale?: boolean
+ isMiss?: boolean
}
type ResponseGenerator = (
@@ -73,6 +90,7 @@ export default class ResponseCache {
const cachedResponse = key ? await this.incrementalCache.get(key) : null
if (cachedResponse) {
resolve({
+ isStale: cachedResponse.isStale,
revalidate: cachedResponse.curRevalidate,
value:
cachedResponse.value?.kind === 'PAGE'
@@ -91,7 +109,10 @@ export default class ResponseCache {
}
const cacheEntry = await responseGenerator(resolved)
- resolve(cacheEntry)
+ resolve({
+ ...(cacheEntry as ResponseCacheEntry),
+ isMiss: !cachedResponse,
+ })
if (key && cacheEntry && typeof cacheEntry.revalidate !== 'undefined') {
await this.incrementalCache.set(
test/integration/image-optimizer/test/index.test.js+200 73
@@ -3,6 +3,7 @@ import execa from 'execa'
import fs from 'fs-extra'
import sizeOf from 'image-size'
import {
+ check,
fetchViaHTTP,
File,
findPort,
@@ -15,6 +16,7 @@ import {
} from 'next-test-utils'
import isAnimated from 'next/dist/compiled/is-animated'
import { join } from 'path'
+import assert from 'assert'
const appDir = join(__dirname, '../app')
const imagesDir = join(appDir, '.next', 'cache', 'images')
@@ -82,11 +84,19 @@ async function expectAvifSmallerThanWebp(w, q) {
expect(avif).toBeLessThanOrEqual(webp)
}
+async function fetchWithDuration(...args) {
+ const start = Date.now()
+ const res = await fetchViaHTTP(...args)
+ const buffer = await res.buffer()
+ const duration = Date.now() - start
+ return { duration, buffer, res }
+}
+
function runTests({
w,
isDev,
domains = [],
- ttl,
+ minimumCacheTTL,
isSharp,
isOutdatedSharp,
avifEnabled,
@@ -502,44 +512,92 @@ function runTests({
it('should use cache and stale-while-revalidate when query is the same for external image', async () => {
await fs.remove(imagesDir)
+ const delay = 500
- const url = 'https://image-optimization-test.vercel.app/test.jpg'
+ const url = `https://image-optimization-test.vercel.app/api/slow?delay=${delay}`
const query = { url, w, q: 39 }
const opts = { headers: { accept: 'image/webp' } }
- const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res1.status).toBe(200)
- expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
- expect(res1.headers.get('Content-Type')).toBe('image/webp')
- expect(res1.headers.get('Content-Disposition')).toBe(
- `inline; filename="test.webp"`
+ const one = await fetchWithDuration(appPort, '/_next/image', query, opts)
+ expect(one.duration).toBeGreaterThan(delay)
+ expect(one.res.status).toBe(200)
+ expect(one.res.headers.get('X-Nextjs-Cache')).toBe('MISS')
+ expect(one.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(one.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="slow.webp"`
)
- const json1 = await fsToJson(imagesDir)
- expect(Object.keys(json1).length).toBe(1)
-
- const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res2.status).toBe(200)
- expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
- expect(res2.headers.get('Content-Type')).toBe('image/webp')
- expect(res2.headers.get('Content-Disposition')).toBe(
- `inline; filename="test.webp"`
+ let json1
+ await check(async () => {
+ json1 = await fsToJson(imagesDir)
+ return Object.keys(json1).length === 1 ? 'success' : 'fail'
+ }, 'success')
+
+ const two = await fetchWithDuration(appPort, '/_next/image', query, opts)
+ expect(two.res.status).toBe(200)
+ expect(two.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
+ expect(two.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(two.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="slow.webp"`
)
const json2 = await fsToJson(imagesDir)
expect(json2).toStrictEqual(json1)
- if (ttl) {
+ if (minimumCacheTTL) {
// Wait until expired so we can confirm image is regenerated
- await waitFor(ttl * 1000)
- const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res3.status).toBe(200)
- expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE')
- expect(res3.headers.get('Content-Type')).toBe('image/webp')
- expect(res3.headers.get('Content-Disposition')).toBe(
- `inline; filename="test.webp"`
+ await waitFor(minimumCacheTTL * 1000)
+
+ const [three, four] = await Promise.all([
+ fetchWithDuration(appPort, '/_next/image', query, opts),
+ fetchWithDuration(appPort, '/_next/image', query, opts),
+ ])
+
+ expect(three.duration).toBeLessThan(one.duration)
+ expect(three.res.status).toBe(200)
+ expect(three.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
+ expect(three.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(three.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="slow.webp"`
)
- const json3 = await fsToJson(imagesDir)
- expect(json3).not.toStrictEqual(json1)
- expect(Object.keys(json3).length).toBe(1)
+
+ expect(four.duration).toBeLessThan(one.duration)
+ expect(four.res.status).toBe(200)
+ expect(four.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
+ expect(four.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(four.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="slow.webp"`
+ )
+ await check(async () => {
+ const json4 = await fsToJson(imagesDir)
+ try {
+ assert.deepStrictEqual(json4, json1)
+ return 'fail'
+ } catch (err) {
+ return 'success'
+ }
+ }, 'success')
+
+ const five = await fetchWithDuration(
+ appPort,
+ '/_next/image',
+ query,
+ opts
+ )
+ expect(five.duration).toBeLessThan(one.duration)
+ expect(five.res.status).toBe(200)
+ expect(five.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
+ expect(five.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(five.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="slow.webp"`
+ )
+ await check(async () => {
+ const json5 = await fsToJson(imagesDir)
+ try {
+ assert.deepStrictEqual(json5, json1)
+ return 'fail'
+ } catch (err) {
+ return 'success'
+ }
+ }, 'success')
}
})
}
@@ -580,39 +638,80 @@ function runTests({
const query = { url: '/test.png', w, q: 80 }
const opts = { headers: { accept: 'image/webp' } }
- const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res1.status).toBe(200)
- expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
- expect(res1.headers.get('Content-Type')).toBe('image/webp')
- expect(res1.headers.get('Content-Disposition')).toBe(
+ const one = await fetchWithDuration(appPort, '/_next/image', query, opts)
+ expect(one.res.status).toBe(200)
+ expect(one.res.headers.get('X-Nextjs-Cache')).toBe('MISS')
+ expect(one.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(one.res.headers.get('Content-Disposition')).toBe(
`inline; filename="test.webp"`
)
- const json1 = await fsToJson(imagesDir)
- expect(Object.keys(json1).length).toBe(1)
-
- const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res2.status).toBe(200)
- expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
- expect(res2.headers.get('Content-Type')).toBe('image/webp')
- expect(res2.headers.get('Content-Disposition')).toBe(
+ let json1
+ await check(async () => {
+ json1 = await fsToJson(imagesDir)
+ return Object.keys(json1).length === 1 ? 'success' : 'fail'
+ }, 'success')
+
+ const two = await fetchWithDuration(appPort, '/_next/image', query, opts)
+ expect(two.res.status).toBe(200)
+ expect(two.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
+ expect(two.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(two.res.headers.get('Content-Disposition')).toBe(
`inline; filename="test.webp"`
)
const json2 = await fsToJson(imagesDir)
expect(json2).toStrictEqual(json1)
- if (ttl) {
+ if (minimumCacheTTL) {
// Wait until expired so we can confirm image is regenerated
- await waitFor(ttl * 1000)
- const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
- expect(res3.status).toBe(200)
- expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE')
- expect(res3.headers.get('Content-Type')).toBe('image/webp')
- expect(res3.headers.get('Content-Disposition')).toBe(
+ await waitFor(minimumCacheTTL * 1000)
+
+ const [three, four] = await Promise.all([
+ fetchWithDuration(appPort, '/_next/image', query, opts),
+ fetchWithDuration(appPort, '/_next/image', query, opts),
+ ])
+
+ expect(three.duration).toBeLessThan(one.duration)
+ expect(three.res.status).toBe(200)
+ expect(three.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
+ expect(three.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(three.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="test.webp"`
+ )
+
+ expect(four.duration).toBeLessThan(one.duration)
+ expect(four.res.status).toBe(200)
+ expect(four.res.headers.get('X-Nextjs-Cache')).toBe('STALE')
+ expect(four.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(four.res.headers.get('Content-Disposition')).toBe(
+ `inline; filename="test.webp"`
+ )
+ await check(async () => {
+ const json3 = await fsToJson(imagesDir)
+ try {
+ assert.deepStrictEqual(json3, json1)
+ return 'fail'
+ } catch (err) {
+ return 'success'
+ }
+ }, 'success')
+
+ const five = await fetchWithDuration(appPort, '/_next/image', query, opts)
+ expect(five.duration).toBeLessThan(one.duration)
+ expect(five.res.status).toBe(200)
+ expect(five.res.headers.get('X-Nextjs-Cache')).toBe('HIT')
+ expect(five.res.headers.get('Content-Type')).toBe('image/webp')
+ expect(five.res.headers.get('Content-Disposition')).toBe(
`inline; filename="test.webp"`
)
- const json3 = await fsToJson(imagesDir)
- expect(json3).not.toStrictEqual(json1)
- expect(Object.keys(json3).length).toBe(1)
+ await check(async () => {
+ const json5 = await fsToJson(imagesDir)
+ try {
+ assert.deepStrictEqual(json5, json1)
+ return 'fail'
+ } catch (err) {
+ return 'success'
+ }
+ }, 'success')
}
})
@@ -629,8 +728,11 @@ function runTests({
expect(res1.headers.get('Content-Disposition')).toBe(
`inline; filename="test.svg"`
)
- const json1 = await fsToJson(imagesDir)
- expect(Object.keys(json1).length).toBe(1)
+ let json1
+ await check(async () => {
+ json1 = await fsToJson(imagesDir)
+ return Object.keys(json1).length === 1 ? 'success' : 'fail'
+ }, 'success')
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res2.status).toBe(200)
@@ -656,8 +758,12 @@ function runTests({
expect(res1.headers.get('Content-Disposition')).toBe(
`inline; filename="animated.gif"`
)
- const json1 = await fsToJson(imagesDir)
- expect(Object.keys(json1).length).toBe(1)
+
+ let json1
+ await check(async () => {
+ json1 = await fsToJson(imagesDir)
+ return Object.keys(json1).length === 1 ? 'success' : 'fail'
+ }, 'success')
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res2.status).toBe(200)
@@ -716,7 +822,7 @@ function runTests({
await expectWidth(res3, w)
})
- it('should proxy-pass unsupported image types and should not cache file', async () => {
+ it('should maintain bmp', async () => {
const json1 = await fsToJson(imagesDir)
expect(json1).toBeTruthy()
@@ -736,8 +842,14 @@ function runTests({
`inline; filename="test.bmp"`
)
- const json2 = await fsToJson(imagesDir)
- expect(json2).toStrictEqual(json1)
+ await check(async () => {
+ try {
+ assert.deepStrictEqual(await fsToJson(imagesDir), json1)
+ return 'expected change, but matched'
+ } catch (_) {
+ return 'success'
+ }
+ }, 'success')
})
it('should not resize if requested width is larger than original source image', async () => {
@@ -840,12 +952,16 @@ function runTests({
await fs.remove(imagesDir)
const query = { url: '/test.png', w, q: 80 }
const opts = { headers: { accept: 'image/webp,*/*' } }
- const [res1, res2] = await Promise.all([
+ const [res1, res2, res3] = await Promise.all([
+ fetchViaHTTP(appPort, '/_next/image', query, opts),
fetchViaHTTP(appPort, '/_next/image', query, opts),
fetchViaHTTP(appPort, '/_next/image', query, opts),
])
+
expect(res1.status).toBe(200)
expect(res2.status).toBe(200)
+ expect(res3.status).toBe(200)
+
expect(res1.headers.get('Content-Type')).toBe('image/webp')
expect(res1.headers.get('Content-Disposition')).toBe(
`inline; filename="test.webp"`
@@ -854,21 +970,28 @@ function runTests({
expect(res2.headers.get('Content-Disposition')).toBe(
`inline; filename="test.webp"`
)
+ expect(res3.headers.get('Content-Type')).toBe('image/webp')
+ expect(res3.headers.get('Content-Disposition')).toBe(
+ `inline; filename="test.webp"`
+ )
+
await expectWidth(res1, w)
await expectWidth(res2, w)
+ await expectWidth(res3, w)
- const json1 = await fsToJson(imagesDir)
- expect(Object.keys(json1).length).toBe(1)
+ await check(async () => {
+ const json1 = await fsToJson(imagesDir)
+ return Object.keys(json1).length === 1 ? 'success' : 'fail'
+ }, 'success')
- const xCache1 = res1.headers.get('X-Nextjs-Cache')
- const xCache2 = res2.headers.get('X-Nextjs-Cache')
- if (xCache1 === 'HIT') {
- expect(xCache1).toBe('HIT')
- expect(xCache2).toBe('MISS')
- } else {
- expect(xCache1).toBe('MISS')
- expect(xCache2).toBe('HIT')
- }
+ const xCache = [res1, res2, res3]
+ .map((r) => r.headers.get('X-Nextjs-Cache'))
+ .sort((a, b) => b.localeCompare(a))
+
+ // Since the first request is a miss it blocks
+ // until the cache be populated so all concurrent
+ // requests receive the same response
+ expect(xCache).toEqual(['MISS', 'MISS', 'MISS'])
})
if (isDev || isSharp) {
@@ -1105,13 +1228,17 @@ describe('Image Optimizer', () => {
'image-optimization-test.vercel.app',
]
+ // Reduce to 5 seconds so tests dont dont need to
+ // wait too long before testing stale responses.
+ const minimumCacheTTL = 5
+
describe('Server support for minimumCacheTTL in next.config.js', () => {
const size = 96 // defaults defined in server/config.ts
- const ttl = 5 // super low ttl in seconds
beforeAll(async () => {
const json = JSON.stringify({
images: {
- minimumCacheTTL: ttl,
+ domains,
+ minimumCacheTTL,
},
})
nextOutput = ''
@@ -1130,7 +1257,7 @@ describe('Image Optimizer', () => {
await fs.remove(imagesDir)
})
- runTests({ w: size, isDev: false, ttl })
+ runTests({ w: size, isDev: false, domains, minimumCacheTTL })
… diff truncated
packages/next/server/image-optimizer.ts+20 24
@@ -45,7 +45,7 @@ try {
let showSharpMissingWarning = process.env.NODE_ENV === 'production'
-interface ParamsResult {
+export interface ImageParamsResult {
href: string
isAbsolute: boolean
isStatic: boolean
@@ -65,8 +65,15 @@ export class ImageOptimizerCache {
query: UrlWithParsedQuery['query'],
nextConfig: NextConfigComplete,
isDev: boolean
- ): ParamsResult | { errorMessage: string } {
- const imageConfig = nextConfig.images
+ ): ImageParamsResult | { errorMessage: string } {
+ const imageData = nextConfig.images
+ const {
+ deviceSizes = [],
+ imageSizes = [],
+ domains = [],
+ minimumCacheTTL = 60,
+ formats = ['image/webp'],
+ } = imageData
const { url, w, q } = query
let href: string
@@ -96,10 +103,7 @@ export class ImageOptimizerCache {
return { errorMessage: '"url" parameter is invalid' }
}
- if (
- !imageConfig.domains ||
- !imageConfig.domains.includes(hrefParsed.hostname)
- ) {
+ if (!domains || !domains.includes(hrefParsed.hostname)) {
return { errorMessage: '"url" parameter is not allowed' }
}
}
@@ -124,10 +128,7 @@ export class ImageOptimizerCache {
}
}
- const sizes = [
- ...(imageConfig.deviceSizes || []),
- ...(imageConfig.imageSizes || []),
- ]
+ const sizes = [...(deviceSizes || []), ...(imageSizes || [])]
if (isDev) {
sizes.push(BLUR_IMG_SIZE)
@@ -148,10 +149,7 @@ export class ImageOptimizerCache {
}
}
- const mimeType = getSupportedMimeType(
- imageConfig.formats || [],
- req.headers['accept']
- )
+ const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])
const isStatic = url.startsWith(
`${nextConfig.basePath || ''}/_next/static/media`
@@ -165,7 +163,7 @@ export class ImageOptimizerCache {
width,
quality,
mimeType,
- minimumCacheTTL: imageConfig.minimumCacheTTL,
+ minimumCacheTTL: minimumCacheTTL,
}
}
@@ -205,7 +203,6 @@ export class ImageOptimizerCache {
const buffer = await promises.readFile(join(cacheDir, file))
const expireAt = Number(expireAtSt)
const maxAge = Number(maxAgeSt)
- const revalidate = maxAge
return {
value: {
@@ -213,9 +210,8 @@ export class ImageOptimizerCache {
etag,
buffer,
extension,
- revalidate,
},
- revalidate,
+ revalidate: maxAge,
isStale: now > expireAt,
}
}
@@ -248,9 +244,9 @@ export class ImageOptimizerCache {
}
}
export class ImageError extends Error {
- statusCode?: number
+ statusCode: number
- constructor(message: string, statusCode?: number) {
+ constructor(message: string, statusCode: number) {
super(message)
this.statusCode = statusCode
}
@@ -259,7 +255,7 @@ export class ImageError extends Error {
export async function imageOptimizer(
_req: IncomingMessage,
_res: ServerResponse,
- paramsResult: ParamsResult,
+ paramsResult: ImageParamsResult,
nextConfig: NextConfigComplete,
handleRequest: (
newReq: IncomingMessage,
@@ -441,7 +437,7 @@ export async function imageOptimizer(
console.error(
`Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly`
)
- throw new ImageError('internal server error')
+ throw new ImageError('internal server error', 500)
}
// Show sharp warning in production once
if (showSharpMissingWarning) {
@@ -511,7 +507,7 @@ export async function imageOptimizer(
maxAge: Math.max(maxAge, nextConfig.images.minimumCacheTTL),
}
} else {
- throw new ImageError('Unable to optimize buffer')
+ throw new ImageError('Unable to optimize buffer', 500)
}
} catch (error) {
return {
More files changed — see the full commit.

References