Repository: vercel/next.js
Pull request: fix(next/image): improve and simplify detect-content-type

Add support for detecting more src image formats via magic number. The src image formats are handled as follows:

- `image/jxl` - serve as is (since safari can render it)
- `image/heic` - serve as is (since safari can render it)
- `image/jp2` - serve as is (since safari can render it)
- `application/pdf` - error (since no browser will render it)
- `image/pic` - error (since no browser will render it)

We also fallback to `sharp().metadata()` if we can't detect the magic number to ensure correctness.

Changed files:
filename: packages/next/src/server/image-optimizer.ts
patch: @@ -36,15 +36,19 @@ const AVIF = 'image/avif'
 const WEBP = 'image/webp'
 const PNG = 'image/png'
 const JPEG = 'image/jpeg'
+const JXL = 'image/jxl'
+const JP2 = 'image/jp2'
+const HEIC = 'image/heic'
 const GIF = 'image/gif'
 const SVG = 'image/svg+xml'
 const ICO = 'image/x-icon'
 const ICNS = 'image/x-icns'
 const TIFF = 'image/tiff'
 const BMP = 'image/bmp'
+const PDF = 'application/pdf'
 const CACHE_VERSION = 4
 const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
-const BYPASS_TYPES = [SVG, ICO, ICNS, BMP]
+const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]
 const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
 const BLUR_QUALITY = 70 // should match `next-image-loader`
 
@@ -152,7 +156,9 @@ async function writeToCacheDir(
  * it matches the "magic number" of known file signatures.
  * https://en.wikipedia.org/wiki/List_of_file_signatures
  */
-export function detectContentType(buffer: Buffer) {
+export async function detectContentType(
+  buffer: Buffer
+): Promise<string | null> {
   if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
     return JPEG
   }
@@ -198,7 +204,77 @@ export function detectContentType(buffer: Buffer) {
   if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
     return BMP
   }
-  return null
+  if ([0xff, 0x0a].every((b, i) => buffer[i] === b)) {
+    return JXL
+  }
+  if (
+    [
+      0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
+    ].every((b, i) => buffer[i] === b)
+  ) {
+    return JXL
+  }
+  if (
+    [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every(
+      (b, i) => !b || buffer[i] === b
+    )
+  ) {
+    return HEIC
+  }
+  if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) {
+    return PDF
+  }
+  if (
+    [
+      0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
+    ].every((b, i) => buffer[i] === b)
+  ) {
+    return JP2
+  }
+
+  const sharp = getSharp(null)
+  const meta = await sharp(buffer)
+    .metadata()
+    .catch((_) => null)
+  switch (meta?.format) {
+    case 'avif':
+      return AVIF
+    case 'webp':
+      return WEBP
+    case 'png':
+      return PNG
+    case 'jpeg':
+    case 'jpg':
+      return JPEG
+    case 'gif':
+      return GIF
+    case 'svg':
+      return SVG
+    case 'jxl':
+      return JXL
+    case 'jp2':
+      return JP2
+    case 'tiff':
+    case 'tif':
+      return TIFF
+    case 'pdf':
+      return PDF
+    case 'dcraw':
+    case 'dz':
+    case 'exr':
+    case 'fits':
+    case 'heif':
+    case 'input':
+    case 'magick':
+    case 'openslide':
+    case 'ppm':
+    case 'rad':
+    case 'raw':
+    case 'v':
+    case undefined:
+    default:
+      return null
+  }
 }
 
 export class ImageOptimizerCache {
@@ -702,58 +778,58 @@ export async function imageOptimizer(
     getMaxAge(imageUpstream.cacheControl)
   )
 
-  const upstreamType =
-    detectContentType(upstreamBuffer) ||
-    imageUpstream.contentType?.toLowerCase().trim()
-
-  if (upstreamType) {
-    if (
-      upstreamType.startsWith('image/svg') &&
-      !nextConfig.images.dangerouslyAllowSVG
-    ) {
-      if (!opts.silent) {
-        Log.error(
-          `The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
-        )
-      }
-      throw new ImageError(
-        400,
-        '"url" parameter is valid but image type is not allowed'
+  const upstreamType = await detectContentType(upstreamBuffer)
+
+  if (
+    !upstreamType ||
+    !upstreamType.startsWith('image/') ||
+    upstreamType.includes(',')
+  ) {
+    if (!opts.silent) {
+      Log.error(
+        "The requested resource isn't a valid image for",
+        href,
+        'received',
+        upstreamType
       )
     }
-    if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
-      if (!opts.silent) {
-        Log.warnOnce(
-          `The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
-        )
-      }
-      return {
-        buffer: upstreamBuffer,
-        contentType: upstreamType,
-        maxAge,
-        etag: upstreamEtag,
-        upstreamEtag,
-      }
+    throw new ImageError(400, "The requested resource isn't a valid image.")
+  }
+  if (
+    upstreamType.startsWith('image/svg') &&
+    !nextConfig.images.dangerouslyAllowSVG
+  ) {
+    if (!opts.silent) {
+      Log.error(
+        `The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
+      )
     }
-    if (BYPASS_TYPES.includes(upstreamType)) {
-      return {
-        buffer: upstreamBuffer,
-        contentType: upstreamType,
-        maxAge,
-        etag: upstreamEtag,
-        upstreamEtag,
-      }
+    throw new ImageError(
+      400,
+      '"url" parameter is valid but image type is not allowed'
+    )
+  }
+  if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
+    if (!opts.silent) {
+      Log.warnOnce(
+        `The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
+      )
     }
-    if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) {
-      if (!opts.silent) {
-        Log.error(
-          "The requested resource isn't a valid image for",
-          href,
-          'received',
-          upstreamType
-        )
-      }
-      throw new ImageError(400, "The requested resource isn't a valid image.")
+    return {
+      buffer: upstreamBuffer,
+      contentType: upstreamType,
+      maxAge,
+      etag: upstreamEtag,
+      upstreamEtag,
+    }
+  }
+  if (BYPASS_TYPES.includes(upstreamType)) {
+    return {
+      buffer: upstreamBuffer,
+      contentType: upstreamType,
+      maxAge,
+      etag: upstreamEtag,
+      upstreamEtag,
     }
   }
 
@@ -762,7 +838,6 @@ export async function imageOptimizer(
   if (mimeType) {
     contentType = mimeType
   } else if (
-    upstreamType?.startsWith('image/') &&
     getExtension(upstreamType) &&
     upstreamType !== WEBP &&
     upstreamType !== AVIF

filename: packages/next/src/server/serve-static.ts
patch: @@ -6,6 +6,8 @@ import send from 'next/dist/compiled/send'
 send.mime.define({
   'image/avif': ['avif'],
   'image/x-icns': ['icns'],
+  'image/jxl': ['jxl'],
+  'image/heic': ['heic'],
 })
 
 export function serveStatic(

filename: test/integration/image-optimizer/test/util.ts
patch: @@ -219,25 +219,52 @@ export function runTests(ctx: RunTestsCtx) {
     await expectWidth(res, 256)
   })
 
-  it('should maintain pic/pct', async () => {
-    const query = { w: ctx.w, q: 90, url: '/test.pic' }
+  it('should maintain jxl', async () => {
+    const query = { w: ctx.w, q: 90, url: '/test.jxl' }
     const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
     expect(res.status).toBe(200)
-    expect(res.headers.get('Content-Type')).toContain('image/x-pict')
+    expect(res.headers.get('Content-Type')).toContain('image/jxl')
     expect(res.headers.get('Cache-Control')).toBe(
       `public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
     )
     expect(res.headers.get('Vary')).toBe('Accept')
     expect(res.headers.get('etag')).toBeTruthy()
     expect(res.headers.get('Content-Disposition')).toBe(
-      `${contentDispositionType}; filename="test.pic"`
+      `${contentDispositionType}; filename="test.jxl"`
     )
-    const actual = await res.text()
-    const expected = await fs.readFile(
-      join(ctx.appDir, 'public', 'test.pic'),
-      'utf8'
+    await expectWidth(res, 800)
+  })
+
+  it('should maintain heic', async () => {
+    const query = { w: ctx.w, q: 90, url: '/test.heic' }
+    const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
+    expect(res.status).toBe(200)
+    expect(res.headers.get('Content-Type')).toContain('image/heic')
+    expect(res.headers.get('Cache-Control')).toBe(
+      `public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
     )
-    expect(actual).toMatch(expected)
+    expect(res.headers.get('Vary')).toBe('Accept')
+    expect(res.headers.get('etag')).toBeTruthy()
+    expect(res.headers.get('Content-Disposition')).toBe(
+      `${contentDispositionType}; filename="test.heic"`
+    )
+    await expectWidth(res, 400)
+  })
+
+  it('should maintain jp2', async () => {
+    const query = { w: ctx.w, q: 90, url: '/test.jp2' }
+    const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
+    expect(res.status).toBe(200)
+    expect(res.headers.get('Content-Type')).toContain('image/jp2')
+    expect(res.headers.get('Cache-Control')).toBe(
+      `public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
+    )
+    expect(res.headers.get('Vary')).toBe('Accept')
+    expect(res.headers.get('etag')).toBeTruthy()
+    expect(res.headers.get('Content-Disposition')).toBe(
+      `${contentDispositionType}; filename="test.jp2"`
+    )
+    await expectWidth(res, 1)
   })
 
   it('should maintain animated gif', async () => {
@@ -339,12 +366,6 @@ export function runTests(ctx: RunTestsCtx) {
         'utf8'
       )
       expect(actual).toMatch(expected)
-      expect(ctx.nextOutput).not.toContain(
-        `The requested resource isn't a valid image`
-      )
-      expect(ctx.nextOutput).not.toContain(
-        `valid but image type is not allowed`
-      )
     })
   } else {
     it('should not allow vector svg', async () => {
@@ -381,7 +402,7 @@ export function runTests(ctx: RunTestsCtx) {
       const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
       expect(res.status).toBe(400)
       expect(await res.text()).toContain(
-        '"url" parameter is valid but image type is not allowed'
+        "The requested resource isn't a valid image"
       )
     })
 
@@ -396,6 +417,16 @@ export function runTests(ctx: RunTestsCtx) {
     })
   }
 
+  it('should not allow pdf format', async () => {
+    const query = { w: ctx.w, q: 90, url: '/test.pdf' }
+    const opts = { headers: { accept: 'image/webp' } }
+    const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
+    expect(res.status).toBe(400)
+    expect(await res.text()).toContain(
+      "The requested resource isn't a valid image"
+    )
+  })
+
   it('should maintain ico format', async () => {
     const query = { w: ctx.w, q: 90, url: `/test.ico` }
     const opts = { headers: { accept: 'image/webp' } }
@@ -1092,8 +1123,8 @@ export function runTests(ctx: RunTestsCtx) {
     const opts = { headers: { accept: 'image/webp' } }
     const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
     expect(res.status).toBe(400)
-    expect(await res.text()).toBe(
-      `Unable to optimize image and unable to fallback to upstream image`
+    expect(await res.text()).toContain(
+      "The requested resource isn't a valid image"
     )
   })
 

filename: test/production/pages-dir/production/fixture/public/xss.svg
patch: @@ -1,9 +1,11 @@
-<html xmlns="http://www.w3.org/1999/xhtml">
-<head>
-  <title>XSS</title>
-</head>
-<body>
-  <p id="msg">safe</p>
-  <script>document.getElementById('msg').textContent='hacked'</script>
-</body>
-</html>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
+  <rect id="box" x="0" y="0" width="100" height="100" fill="red" />
+
+  <script type="application/javascript"><![CDATA[
+    if (!document.getElementById('msg')) {
+      document.body.innerHTML = '<p id="msg">safe</p>'
+    }
+    document.getElementById('msg').textContent='hacked'
+  ]]></script>
+</svg>

filename: test/unit/image-optimizer/detect-content-type.test.ts
patch: @@ -8,42 +8,76 @@ const getImage = (filepath) => readFile(join(__dirname, filepath))
 describe('detectContentType', () => {
   it('should return jpg', async () => {
     const buffer = await getImage('./images/test.jpg')
-    expect(detectContentType(buffer)).toBe('image/jpeg')
+    expect(await detectContentType(buffer)).toBe('image/jpeg')
   })
   it('should return png', async () => {
     const buffer = await getImage('./images/test.png')
-    expect(detectContentType(buffer)).toBe('image/png')
+    expect(await detectContentType(buffer)).toBe('image/png')
   })
   it('should return webp', async () => {
     const buffer = await getImage('./images/animated.webp')
-    expect(detectContentType(buffer)).toBe('image/webp')
+    expect(await detectContentType(buffer)).toBe('image/webp')
   })
   it('should return svg', async () => {
     const buffer = await getImage('./images/test.svg')
-    expect(detectContentType(buffer)).toBe('image/svg+xml')
+    expect(await detectContentType(buffer)).toBe('image/svg+xml')
   })
   it('should return svg for inline svg', async () => {
     const buffer = await getImage('./images/test-inline.svg')
-    expect(detectContentType(buffer)).toBe('image/svg+xml')
+    expect(await detectContentType(buffer)).toBe('image/svg+xml')
+  })
+  it('should return svg when starts with space', async () => {
+    const buffer = Buffer.from(
+      ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+    )
+    expect(await detectContentType(buffer)).toBe('image/svg+xml')
+  })
+  it('should return svg when starts with newline', async () => {
+    const buffer = Buffer.from(
+      '\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+    )
+    expect(await detectContentType(buffer)).toBe('image/svg+xml')
+  })
+  it('should return svg when starts with tab', async () => {
+    const buffer = Buffer.from(
+      '\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+    )
+    expect(await detectContentType(buffer)).toBe('image/svg+xml')
   })
   it('should return avif', async () => {
     const buffer = await getImage('./images/test.avif')
-    expect(detectContentType(buffer)).toBe('image/avif')
+    expect(await detectContentType(buffer)).toBe('image/avif')
   })
   it('should return icon', async () => {
     const buffer = await getImage('./images/test.ico')
-    expect(detectContentType(buffer)).toBe('image/x-icon')
+    expect(await detectContentType(buffer)).toBe('image/x-icon')
   })
   it('should return icns', async () => {
     const buffer = await getImage('./images/test.icns')
-    expect(detectContentType(buffer)).toBe('image/x-icns')
+    expect(await detectContentType(buffer)).toBe('image/x-icns')
+  })
+  it('should return jxl', async () => {
+    const buffer = await getImage('./images/test.jxl')
+    expect(await detectContentType(buffer)).toBe('image/jxl')
+  })
+  it('should return jp2', async () => {
+    const buffer = await getImage('./images/test.jp2')
+    expect(await detectContentType(buffer)).toBe('image/jp2')
+  })
+  it('should return heic', async () => {
+    const buffer = await getImage('./images/test.heic')
+    expect(await detectContentType(buffer)).toBe('image/heic')
+  })
+  it('should return pdf', async () => {
+    const buffer = await getImage('./images/test.pdf')
+    expect(await detectContentType(buffer)).toBe('application/pdf')
   })
   it('should return tiff', async () => {
     const buffer = await getImage('./images/test.tiff')
-    expect(detectContentType(buffer)).toBe('image/tiff')
+    expect(await detectContentType(buffer)).toBe('image/tiff')
   })
   it('should return bmp', async () => {
     const buffer = await getImage('./images/test.bmp')
-    expect(detectContentType(buffer)).toBe('image/bmp')
+    expect(await detectContentType(buffer)).toBe('image/bmp')
   })
 })