Skip to content

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

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 1 commit into from
Jul 29, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
179 changes: 127 additions & 52 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -703,58 +779,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 (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PDF files can be detected and returned by detectContentType() as application/pdf, but the validation logic will incorrectly reject them with "isn't a valid image" message instead of the intended behavior.

View Details

Analysis

The detectContentType() function can detect PDF files (line 224-225) and return 'application/pdf' via Sharp metadata fallback (line 260-261). However, the validation logic at lines 784-797 checks if !upstreamType.startsWith('image/') and throws an error saying "The requested resource isn't a valid image."

While PDFs should indeed be rejected (as confirmed by the test expecting this behavior), the logic is flawed because:

  1. PDF detection is implemented in detectContentType()
  2. PDF constant is defined as 'application/pdf'
  3. The validation rejects anything not starting with 'image/'
  4. But PDF files will be detected and then rejected with the generic "isn't a valid image" error

This creates inconsistent behavior where PDF files are explicitly detected but then rejected by generic validation logic, rather than having explicit handling for the PDF case.


Recommendation

Move the PDF validation to occur before the generic image validation, or exclude PDF detection entirely from detectContentType() since PDFs should not be processed by the image optimizer. The current approach of detecting PDFs but then rejecting them with a generic message is confusing and inconsistent with the explicit handling of other non-image formats.

!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 @@ -763,7 +839,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 added test/integration/image-optimizer/app/public/test.jp2
Binary file not shown.
Binary file not shown.
Binary file not shown.
67 changes: 49 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,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 () => {
Expand Down Expand Up @@ -332,12 +359,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 @@ -374,7 +395,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 @@ -389,6 +410,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 @@ -1085,8 +1116,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
20 changes: 11 additions & 9 deletions test/production/pages-dir/production/fixture/public/xss.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading