Skip to content

Commit 5dd68a5

Browse files
ztannerstyfle
andauthored
[backport v14]: fix(next/image): improve and simplify detect-content-type (#82118) (#82179)
Backports: - #82118 --------- Co-authored-by: Steven <[email protected]>
1 parent bcc7c65 commit 5dd68a5

File tree

18 files changed

+258
-55
lines changed

18 files changed

+258
-55
lines changed

packages/next/src/server/image-optimizer.ts

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,19 @@ const AVIF = 'image/avif'
3737
const WEBP = 'image/webp'
3838
const PNG = 'image/png'
3939
const JPEG = 'image/jpeg'
40+
const JXL = 'image/jxl'
41+
const JP2 = 'image/jp2'
42+
const HEIC = 'image/heic'
4043
const GIF = 'image/gif'
4144
const SVG = 'image/svg+xml'
4245
const ICO = 'image/x-icon'
46+
const ICNS = 'image/x-icns'
47+
const TIFF = 'image/tiff'
48+
const BMP = 'image/bmp'
49+
const PDF = 'application/pdf'
4350
const CACHE_VERSION = 3
4451
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
45-
const VECTOR_TYPES = [SVG]
52+
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]
4653
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
4754
const BLUR_QUALITY = 70 // should match `next-image-loader`
4855

@@ -118,7 +125,9 @@ async function writeToCacheDir(
118125
* it matches the "magic number" of known file signatures.
119126
* https://en.wikipedia.org/wiki/List_of_file_signatures
120127
*/
121-
export function detectContentType(buffer: Buffer) {
128+
export async function detectContentType(
129+
buffer: Buffer
130+
): Promise<string | null> {
122131
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
123132
return JPEG
124133
}
@@ -152,6 +161,72 @@ export function detectContentType(buffer: Buffer) {
152161
if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) {
153162
return ICO
154163
}
164+
if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) {
165+
return ICNS
166+
}
167+
if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) {
168+
return TIFF
169+
}
170+
if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
171+
return BMP
172+
}
173+
if ([0xff, 0x0a].every((b, i) => buffer[i] === b)) {
174+
return JXL
175+
}
176+
if (
177+
[
178+
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
179+
].every((b, i) => buffer[i] === b)
180+
) {
181+
return JXL
182+
}
183+
if (
184+
[0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every(
185+
(b, i) => !b || buffer[i] === b
186+
)
187+
) {
188+
return HEIC
189+
}
190+
if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) {
191+
return PDF
192+
}
193+
if (
194+
[
195+
0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
196+
].every((b, i) => buffer[i] === b)
197+
) {
198+
return JP2
199+
}
200+
201+
// Fallback to sharp if available
202+
if (sharp) {
203+
const meta = await sharp(buffer)
204+
.metadata()
205+
.catch((_) => null)
206+
switch (meta?.format) {
207+
case 'avif':
208+
return AVIF
209+
case 'webp':
210+
return WEBP
211+
case 'png':
212+
return PNG
213+
case 'jpeg':
214+
case 'jpg':
215+
return JPEG
216+
case 'gif':
217+
return GIF
218+
case 'svg':
219+
return SVG
220+
case 'tiff':
221+
case 'tif':
222+
return TIFF
223+
case 'heif':
224+
return HEIC
225+
default:
226+
return null
227+
}
228+
}
229+
155230
return null
156231
}
157232

@@ -639,53 +714,48 @@ export async function imageOptimizer(
639714
const { href, quality, width, mimeType } = paramsResult
640715
const upstreamBuffer = imageUpstream.buffer
641716
const maxAge = getMaxAge(imageUpstream.cacheControl)
642-
const upstreamType =
643-
detectContentType(upstreamBuffer) ||
644-
imageUpstream.contentType?.toLowerCase().trim()
645-
646-
if (upstreamType) {
647-
if (
648-
upstreamType.startsWith('image/svg') &&
649-
!nextConfig.images.dangerouslyAllowSVG
650-
) {
651-
Log.error(
652-
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
653-
)
654-
throw new ImageError(
655-
400,
656-
'"url" parameter is valid but image type is not allowed'
657-
)
658-
}
717+
const upstreamType = await detectContentType(upstreamBuffer)
659718

660-
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
661-
Log.warnOnce(
662-
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
663-
)
664-
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
665-
}
666-
if (VECTOR_TYPES.includes(upstreamType)) {
667-
// We don't warn here because we already know that "dangerouslyAllowSVG"
668-
// was enabled above, therefore the user explicitly opted in.
669-
// If we add more VECTOR_TYPES besides SVG, perhaps we could warn for those.
670-
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
671-
}
672-
if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) {
673-
Log.error(
674-
"The requested resource isn't a valid image for",
675-
href,
676-
'received',
677-
upstreamType
678-
)
679-
throw new ImageError(400, "The requested resource isn't a valid image.")
680-
}
719+
if (
720+
!upstreamType ||
721+
!upstreamType.startsWith('image/') ||
722+
upstreamType.includes(',')
723+
) {
724+
Log.error(
725+
"The requested resource isn't a valid image for",
726+
href,
727+
'received',
728+
upstreamType
729+
)
730+
throw new ImageError(400, "The requested resource isn't a valid image.")
731+
}
732+
if (
733+
upstreamType.startsWith('image/svg') &&
734+
!nextConfig.images.dangerouslyAllowSVG
735+
) {
736+
Log.error(
737+
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
738+
)
739+
throw new ImageError(
740+
400,
741+
'"url" parameter is valid but image type is not allowed'
742+
)
743+
}
744+
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
745+
Log.warnOnce(
746+
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
747+
)
748+
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
749+
}
750+
if (BYPASS_TYPES.includes(upstreamType)) {
751+
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
681752
}
682753

683754
let contentType: string
684755

685756
if (mimeType) {
686757
contentType = mimeType
687758
} else if (
688-
upstreamType?.startsWith('image/') &&
689759
getExtension(upstreamType) &&
690760
upstreamType !== WEBP &&
691761
upstreamType !== AVIF

packages/next/src/server/serve-static.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import send from 'next/dist/compiled/send'
55
// Although "mime" has already add avif in version 2.4.7, "send" is still using [email protected]
66
send.mime.define({
77
'image/avif': ['avif'],
8+
'image/x-icns': ['icns'],
9+
'image/jxl': ['jxl'],
10+
'image/heic': ['heic'],
811
})
912

1013
export function serveStatic(
2.5 KB
Binary file not shown.
90.8 KB
Binary file not shown.
242 Bytes
Binary file not shown.
45.8 KB
Binary file not shown.
5.98 KB
Binary file not shown.

test/integration/image-optimizer/test/util.ts

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,72 @@ export function runTests(ctx) {
196196
expect(res.status).toBe(200)
197197
})
198198

199+
it('should maintain icns', async () => {
200+
const query = { w: ctx.w, q: 90, url: '/test.icns' }
201+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
202+
expect(res.status).toBe(200)
203+
expect(res.headers.get('Content-Type')).toContain('image/x-icns')
204+
expect(res.headers.get('Cache-Control')).toBe(
205+
`public, max-age=0, must-revalidate`
206+
)
207+
expect(res.headers.get('Vary')).toBe('Accept')
208+
expect(res.headers.get('etag')).toBeTruthy()
209+
expect(res.headers.get('Content-Disposition')).toBe(
210+
`${contentDispositionType}; filename="test.icns"`
211+
)
212+
await expectWidth(res, 256)
213+
})
214+
215+
it('should maintain jxl', async () => {
216+
const query = { w: ctx.w, q: 90, url: '/test.jxl' }
217+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
218+
expect(res.status).toBe(200)
219+
expect(res.headers.get('Content-Type')).toContain('image/jxl')
220+
expect(res.headers.get('Cache-Control')).toBe(
221+
`public, max-age=0, must-revalidate`
222+
)
223+
expect(res.headers.get('Vary')).toBe('Accept')
224+
expect(res.headers.get('etag')).toBeTruthy()
225+
expect(res.headers.get('Content-Disposition')).toBe(
226+
`${contentDispositionType}; filename="test.jxl"`
227+
)
228+
// JXL is a bypass type, served as-is without processing
229+
// [email protected] doesn't support JXL, so skip width check
230+
})
231+
232+
it('should maintain heic', async () => {
233+
const query = { w: ctx.w, q: 90, url: '/test.heic' }
234+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
235+
expect(res.status).toBe(200)
236+
expect(res.headers.get('Content-Type')).toContain('image/heic')
237+
expect(res.headers.get('Cache-Control')).toBe(
238+
`public, max-age=0, must-revalidate`
239+
)
240+
expect(res.headers.get('Vary')).toBe('Accept')
241+
expect(res.headers.get('etag')).toBeTruthy()
242+
expect(res.headers.get('Content-Disposition')).toBe(
243+
`${contentDispositionType}; filename="test.heic"`
244+
)
245+
// HEIC is a bypass type, served as-is without processing
246+
// [email protected] doesn't support HEIC, so skip width check
247+
})
248+
249+
it('should maintain jp2', async () => {
250+
const query = { w: ctx.w, q: 90, url: '/test.jp2' }
251+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
252+
expect(res.status).toBe(200)
253+
expect(res.headers.get('Content-Type')).toContain('image/jp2')
254+
expect(res.headers.get('Cache-Control')).toBe(
255+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
256+
)
257+
expect(res.headers.get('Vary')).toBe('Accept')
258+
expect(res.headers.get('etag')).toBeTruthy()
259+
expect(res.headers.get('Content-Disposition')).toBe(
260+
`${contentDispositionType}; filename="test.jp2"`
261+
)
262+
await expectWidth(res, 1)
263+
})
264+
199265
it('should maintain animated gif', async () => {
200266
const query = { w: ctx.w, q: 90, url: '/animated.gif' }
201267
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
@@ -295,7 +361,6 @@ export function runTests(ctx) {
295361
'utf8'
296362
)
297363
expect(actual).toMatch(expected)
298-
expect(ctx.nextOutput).not.toContain('The requested resource')
299364
})
300365
} else {
301366
it('should not allow vector svg', async () => {
@@ -312,7 +377,7 @@ export function runTests(ctx) {
312377
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
313378
expect(res.status).toBe(400)
314379
expect(await res.text()).toContain(
315-
"The requested resource isn't a valid image"
380+
'"url" parameter is valid but image type is not allowed'
316381
)
317382
})
318383

@@ -322,7 +387,7 @@ export function runTests(ctx) {
322387
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
323388
expect(res.status).toBe(400)
324389
expect(await res.text()).toContain(
325-
"The requested resource isn't a valid image"
390+
'"url" parameter is valid but image type is not allowed'
326391
)
327392
})
328393

@@ -337,14 +402,24 @@ export function runTests(ctx) {
337402
})
338403
}
339404

405+
it('should not allow pdf format', async () => {
406+
const query = { w: ctx.w, q: 90, url: '/test.pdf' }
407+
const opts = { headers: { accept: 'image/webp' } }
408+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
409+
expect(res.status).toBe(400)
410+
expect(await res.text()).toContain(
411+
"The requested resource isn't a valid image"
412+
)
413+
})
414+
340415
it('should maintain ico format', async () => {
341416
const query = { w: ctx.w, q: 90, url: `/test.ico` }
342417
const opts = { headers: { accept: 'image/webp' } }
343418
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
344419
expect(res.status).toBe(200)
345420
expect(res.headers.get('Content-Type')).toContain('image/x-icon')
346421
expect(res.headers.get('Cache-Control')).toBe(
347-
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
422+
`public, max-age=0, must-revalidate`
348423
)
349424
expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/)
350425
expect(res.headers.get('etag')).toBeTruthy()
@@ -940,8 +1015,8 @@ export function runTests(ctx) {
9401015
const opts = { headers: { accept: 'image/webp' } }
9411016
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
9421017
expect(res.status).toBe(400)
943-
expect(await res.text()).toBe(
944-
`Unable to optimize image and unable to fallback to upstream image`
1018+
expect(await res.text()).toContain(
1019+
"The requested resource isn't a valid image"
9451020
)
9461021
})
9471022

@@ -1186,7 +1261,7 @@ export function runTests(ctx) {
11861261
expect(res.status).toBe(200)
11871262
expect(res.headers.get('Content-Type')).toBe('image/bmp')
11881263
expect(res.headers.get('Cache-Control')).toBe(
1189-
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
1264+
`public, max-age=0, must-revalidate`
11901265
)
11911266
// bmp is compressible so will have accept-encoding set from
11921267
// compression

test/production/pages-dir/production/test/security.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,9 @@ export default (next: NextInstance) => {
330330
next.appPort,
331331
'/_next/image?url=%2Fxss.svg&w=256&q=75'
332332
)
333-
expect(await browser.elementById('msg').text()).toBe('safe')
333+
expect(await browser.elementByCss('body').text()).toBe(
334+
"The requested resource isn't a valid image."
335+
)
334336
} finally {
335337
if (browser) await browser.close()
336338
}

0 commit comments

Comments
 (0)