fix(next/image): improve and simplify detect-content-type (#82118)

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.
This commit is contained in:
Steven
2025-07-28 16:20:55 -04:00
committed by GitHub
parent 5c25087d57
commit c49b7b11e8
13 changed files with 232 additions and 88 deletions
+126 -51
View File
@@ -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()
const upstreamType = await detectContentType(upstreamBuffer)
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'
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
+2
View File
@@ -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(
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49 -18
View File
@@ -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"
)
})
@@ -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>

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 406 B

@@ -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')
})
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.