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(
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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)