mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
Binary file not shown.
@@ -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.
Reference in New Issue
Block a user