Improper CSP in Image Optimization API for Next.js versions between 10.0.0 and 12.1.0
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
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
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: IncrementalCacheprivate responseCache: ResponseCache+protected imageResponseCache: ResponseCacheprotected router: Routerprotected dynamicRoutes?: DynamicRoutesprotected 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?: stringconfigFile?: 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 = 3const 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 valuehtml: stringpageData: Object}-export type IncrementalCacheValue = CachedRedirectValue | CachedPageValue+export type IncrementalCacheValue =+| CachedRedirectValue+| CachedPageValue+| CachedImageValuetype 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 valuereturn 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 = 400res.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 stringhtml: RenderResultpageData: 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+| CachedImageValueexport type ResponseCacheEntry = {revalidate?: number | falsevalue: ResponseCacheValue | null+isStale?: boolean+isMiss?: boolean}type ResponseGenerator = (@@ -73,6 +90,7 @@ export default class ResponseCache {const cachedResponse = key ? await this.incrementalCache.get(key) : nullif (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 secondsbeforeAll(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: stringisAbsolute: booleanisStatic: 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'],+} = imageDataconst { url, w, q } = querylet 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 = maxAgereturn {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 onceif (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 {