Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 120 additions & 51 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ const AVIF = 'image/avif'
const WEBP = 'image/webp'
const PNG = 'image/png'
const JPEG = 'image/jpeg'
const JXL = 'image/jxl'
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`

Expand Down Expand Up @@ -152,7 +155,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
}
Expand Down Expand Up @@ -198,6 +203,71 @@ export function detectContentType(buffer: Buffer) {
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
}

const sharp = getSharp(null)
const meta = await sharp(buffer)
.metadata()
.catch((_) => null)

if (meta?.format) {
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 'tiff':
case 'tif':
return TIFF
case 'pdf':
return PDF
case 'dcraw':
case 'dz':
case 'exr':
case 'fits':
case 'heif':
case 'input':
case 'jp2':
case 'magick':
case 'openslide':
case 'ppm':
case 'rad':
case 'raw':
case 'v':
default:
return null
}
}
return null
}

Expand Down Expand Up @@ -702,58 +772,58 @@ export async function imageOptimizer(
getMaxAge(imageUpstream.cacheControl)
)

const upstreamType =
detectContentType(upstreamBuffer) ||
imageUpstream.contentType?.toLowerCase().trim()

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'
const upstreamType = await detectContentType(upstreamBuffer)

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,
}
}

Expand All @@ -762,7 +832,6 @@ export async function imageOptimizer(
if (mimeType) {
contentType = mimeType
} else if (
upstreamType?.startsWith('image/') &&
getExtension(upstreamType) &&
upstreamType !== WEBP &&
upstreamType !== AVIF
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
51 changes: 33 additions & 18 deletions test/integration/image-optimizer/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,25 +219,36 @@ 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 animated gif', async () => {
Expand Down Expand Up @@ -339,12 +350,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 () => {
Expand Down Expand Up @@ -381,7 +386,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"
)
})

Expand All @@ -396,6 +401,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' } }
Expand Down Expand Up @@ -1092,8 +1107,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"
)
})

Expand Down
Loading
Loading