diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index a8d0319630400..7717dd5f39434 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -37,12 +37,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 = 3 const ANIMATABLE_TYPES = [WEBP, PNG, GIF] -const VECTOR_TYPES = [SVG] +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` @@ -118,7 +125,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 { if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) { return JPEG } @@ -152,6 +161,72 @@ export function detectContentType(buffer: Buffer) { if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) { return ICO } + if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) { + return ICNS + } + if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) { + return TIFF + } + if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) { + return BMP + } + 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 + } + + // Fallback to sharp if available + if (sharp) { + 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 'tiff': + case 'tif': + return TIFF + case 'heif': + return HEIC + default: + return null + } + } + return null } @@ -640,45 +715,41 @@ export async function imageOptimizer( const { href, quality, width, mimeType } = paramsResult const upstreamBuffer = imageUpstream.buffer const maxAge = getMaxAge(imageUpstream.cacheControl) - const upstreamType = - detectContentType(upstreamBuffer) || - imageUpstream.contentType?.toLowerCase().trim() - - if (upstreamType) { - if ( - upstreamType.startsWith('image/svg') && - !nextConfig.images.dangerouslyAllowSVG - ) { - 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 (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) { - Log.warnOnce( - `The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the .` - ) - return { buffer: upstreamBuffer, contentType: upstreamType, maxAge } - } - if (VECTOR_TYPES.includes(upstreamType)) { - // We don't warn here because we already know that "dangerouslyAllowSVG" - // was enabled above, therefore the user explicitly opted in. - // If we add more VECTOR_TYPES besides SVG, perhaps we could warn for those. - return { buffer: upstreamBuffer, contentType: upstreamType, maxAge } - } - if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) { - 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.") - } + if ( + !upstreamType || + !upstreamType.startsWith('image/') || + upstreamType.includes(',') + ) { + 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.") + } + if ( + upstreamType.startsWith('image/svg') && + !nextConfig.images.dangerouslyAllowSVG + ) { + 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 (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) { + Log.warnOnce( + `The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the .` + ) + return { buffer: upstreamBuffer, contentType: upstreamType, maxAge } + } + if (BYPASS_TYPES.includes(upstreamType)) { + return { buffer: upstreamBuffer, contentType: upstreamType, maxAge } } let contentType: string @@ -686,7 +757,6 @@ export async function imageOptimizer( if (mimeType) { contentType = mimeType } else if ( - upstreamType?.startsWith('image/') && getExtension(upstreamType) && upstreamType !== WEBP && upstreamType !== AVIF diff --git a/packages/next/src/server/serve-static.ts b/packages/next/src/server/serve-static.ts index e1458730df78b..3202f59fe721d 100644 --- a/packages/next/src/server/serve-static.ts +++ b/packages/next/src/server/serve-static.ts @@ -5,6 +5,9 @@ import send from 'next/dist/compiled/send' // Although "mime" has already add avif in version 2.4.7, "send" is still using mime@1.6.0 send.mime.define({ 'image/avif': ['avif'], + 'image/x-icns': ['icns'], + 'image/jxl': ['jxl'], + 'image/heic': ['heic'], }) export function serveStatic( diff --git a/test/integration/image-optimizer/app/public/test.heic b/test/integration/image-optimizer/app/public/test.heic new file mode 100644 index 0000000000000..748bdee125fac Binary files /dev/null and b/test/integration/image-optimizer/app/public/test.heic differ diff --git a/test/integration/image-optimizer/app/public/test.icns b/test/integration/image-optimizer/app/public/test.icns new file mode 100644 index 0000000000000..69b298b8fff28 Binary files /dev/null and b/test/integration/image-optimizer/app/public/test.icns differ diff --git a/test/integration/image-optimizer/app/public/test.jp2 b/test/integration/image-optimizer/app/public/test.jp2 new file mode 100644 index 0000000000000..0aa9268d723ff Binary files /dev/null and b/test/integration/image-optimizer/app/public/test.jp2 differ diff --git a/test/integration/image-optimizer/app/public/test.jxl b/test/integration/image-optimizer/app/public/test.jxl new file mode 100644 index 0000000000000..d1a5a7d0da029 Binary files /dev/null and b/test/integration/image-optimizer/app/public/test.jxl differ diff --git a/test/integration/image-optimizer/app/public/test.pdf b/test/integration/image-optimizer/app/public/test.pdf new file mode 100644 index 0000000000000..5dd8db5d9d738 Binary files /dev/null and b/test/integration/image-optimizer/app/public/test.pdf differ diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index bf42d48c0c305..aa0e67f1e13a5 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -196,6 +196,72 @@ export function runTests(ctx) { expect(res.status).toBe(200) }) + it('should maintain icns', async () => { + const query = { w: ctx.w, q: 90, url: '/test.icns' } + const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('image/x-icns') + expect(res.headers.get('Cache-Control')).toBe( + `public, max-age=0, 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.icns"` + ) + await expectWidth(res, 256) + }) + + 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/jxl') + expect(res.headers.get('Cache-Control')).toBe( + `public, max-age=0, 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.jxl"` + ) + // JXL is a bypass type, served as-is without processing + // image-size@1.0.0 doesn't support JXL, so skip width check + }) + + 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=0, 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.heic"` + ) + // HEIC is a bypass type, served as-is without processing + // image-size@1.0.0 doesn't support HEIC, so skip width check + }) + + 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 () => { const query = { w: ctx.w, q: 90, url: '/animated.gif' } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {}) @@ -288,7 +354,6 @@ export function runTests(ctx) { 'utf8' ) expect(actual).toMatch(expected) - expect(ctx.nextOutput).not.toContain('The requested resource') }) } else { it('should not allow vector svg', async () => { @@ -305,7 +370,7 @@ export function runTests(ctx) { 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" + '"url" parameter is valid but image type is not allowed' ) }) @@ -315,7 +380,7 @@ export function runTests(ctx) { 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" + '"url" parameter is valid but image type is not allowed' ) }) @@ -330,6 +395,16 @@ export function runTests(ctx) { }) } + 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' } } @@ -337,7 +412,7 @@ export function runTests(ctx) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('image/x-icon') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate` + `public, max-age=0, must-revalidate` ) expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/) expect(res.headers.get('etag')).toBeTruthy() @@ -933,8 +1008,8 @@ export function runTests(ctx) { 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" ) }) @@ -1179,7 +1254,7 @@ export function runTests(ctx) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/bmp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate` + `public, max-age=0, must-revalidate` ) // bmp is compressible so will have accept-encoding set from // compression diff --git a/test/production/pages-dir/production/test/security.ts b/test/production/pages-dir/production/test/security.ts index 779f8270ec297..c22dfdc9b1503 100644 --- a/test/production/pages-dir/production/test/security.ts +++ b/test/production/pages-dir/production/test/security.ts @@ -330,7 +330,9 @@ export default (next: NextInstance) => { next.appPort, '/_next/image?url=%2Fxss.svg&w=256&q=75' ) - expect(await browser.elementById('msg').text()).toBe('safe') + expect(await browser.elementByCss('body').text()).toBe( + "The requested resource isn't a valid image." + ) } finally { if (browser) await browser.close() } diff --git a/test/unit/image-optimizer/detect-content-type.test.ts b/test/unit/image-optimizer/detect-content-type.test.ts index 24df3cd9a48d3..87ad8029d4ae7 100644 --- a/test/unit/image-optimizer/detect-content-type.test.ts +++ b/test/unit/image-optimizer/detect-content-type.test.ts @@ -8,26 +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(await detectContentType(buffer)).toBe('image/svg+xml') + }) + it('should return svg when starts with space', async () => { + const buffer = Buffer.from( + ' ' + ) + expect(await detectContentType(buffer)).toBe('image/svg+xml') + }) + it('should return svg when starts with newline', async () => { + const buffer = Buffer.from( + '\n' + ) + expect(await detectContentType(buffer)).toBe('image/svg+xml') + }) + it('should return svg when starts with tab', async () => { + const buffer = Buffer.from( + '\t' + ) + 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(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(await detectContentType(buffer)).toBe('image/tiff') + }) + it('should return bmp', async () => { + const buffer = await getImage('./images/test.bmp') + expect(await detectContentType(buffer)).toBe('image/bmp') }) }) diff --git a/test/unit/image-optimizer/images/test-inline.svg b/test/unit/image-optimizer/images/test-inline.svg new file mode 100644 index 0000000000000..811eeaf6b154e --- /dev/null +++ b/test/unit/image-optimizer/images/test-inline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/test/unit/image-optimizer/images/test.bmp b/test/unit/image-optimizer/images/test.bmp new file mode 100644 index 0000000000000..f33feda8616b7 Binary files /dev/null and b/test/unit/image-optimizer/images/test.bmp differ diff --git a/test/unit/image-optimizer/images/test.heic b/test/unit/image-optimizer/images/test.heic new file mode 100644 index 0000000000000..748bdee125fac Binary files /dev/null and b/test/unit/image-optimizer/images/test.heic differ diff --git a/test/unit/image-optimizer/images/test.icns b/test/unit/image-optimizer/images/test.icns new file mode 100644 index 0000000000000..69b298b8fff28 Binary files /dev/null and b/test/unit/image-optimizer/images/test.icns differ diff --git a/test/unit/image-optimizer/images/test.jp2 b/test/unit/image-optimizer/images/test.jp2 new file mode 100644 index 0000000000000..0aa9268d723ff Binary files /dev/null and b/test/unit/image-optimizer/images/test.jp2 differ diff --git a/test/unit/image-optimizer/images/test.jxl b/test/unit/image-optimizer/images/test.jxl new file mode 100644 index 0000000000000..d1a5a7d0da029 Binary files /dev/null and b/test/unit/image-optimizer/images/test.jxl differ diff --git a/test/unit/image-optimizer/images/test.pdf b/test/unit/image-optimizer/images/test.pdf new file mode 100644 index 0000000000000..5dd8db5d9d738 Binary files /dev/null and b/test/unit/image-optimizer/images/test.pdf differ diff --git a/test/unit/image-optimizer/images/test.tiff b/test/unit/image-optimizer/images/test.tiff new file mode 100644 index 0000000000000..c2cc3e203bb3f Binary files /dev/null and b/test/unit/image-optimizer/images/test.tiff differ