Thanks to visit codestin.com
Credit goes to github.com

Skip to content
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
152 changes: 111 additions & 41 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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<string | null> {
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
return JPEG
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -640,53 +715,48 @@ 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 <Image>.`
)
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 <Image>.`
)
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
}
if (BYPASS_TYPES.includes(upstreamType)) {
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
}

let contentType: string

if (mimeType) {
contentType = mimeType
} else if (
upstreamType?.startsWith('image/') &&
getExtension(upstreamType) &&
upstreamType !== WEBP &&
upstreamType !== AVIF
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/server/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected]
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 added test/integration/image-optimizer/app/public/test.jp2
Binary file not shown.
Binary file not shown.
Binary file not shown.
89 changes: 82 additions & 7 deletions test/integration/image-optimizer/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// [email protected] 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
// [email protected] 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, {})
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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'
)
})

Expand All @@ -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'
)
})

Expand All @@ -330,14 +395,24 @@ 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' } }
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
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()
Expand Down Expand Up @@ -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"
)
})

Expand Down Expand Up @@ -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`
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I wouldn't expect this to change 🤔

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see the same for ico so must be because of the bypass.

)
// bmp is compressible so will have accept-encoding set from
// compression
Expand Down
4 changes: 3 additions & 1 deletion test/production/pages-dir/production/test/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading
Loading