Skip to content

Commit 8448b01

Browse files
committed
Disable AVIF input optimization when sharp bundles a vulnerable libheif
1 parent 88e34e7 commit 8448b01

6 files changed

Lines changed: 167 additions & 12 deletions

File tree

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,47 @@ export function getSharp(concurrency: number | null | undefined) {
112112
return _sharp
113113
}
114114

115+
// libheif before 1.23.2 has critical memory safety vulnerabilities in AVIF
116+
// decoding (GHSA-g89c-p67h-r497 and GHSA-2jg2-4ch7-h545). sharp only bundles
117+
// a patched libheif starting with 0.35.4, and Node 18 installs always resolve
118+
// the vulnerable 0.34 line, so the decoder version is checked at runtime
119+
// instead of relying on the sharp version.
120+
// Exported for test integration only.
121+
export function isAvifDecodeSafe(heifVersion: string | null): boolean {
122+
if (heifVersion === null) {
123+
return false
124+
}
125+
const [major = Number.NaN, minor = Number.NaN, patch = Number.NaN] =
126+
heifVersion.split('.').map((part) => Number.parseInt(part, 10))
127+
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) {
128+
return false
129+
}
130+
return (
131+
major > 1 ||
132+
(major === 1 && minor > 23) ||
133+
(major === 1 && minor === 23 && patch >= 2)
134+
)
135+
}
136+
137+
let _avifDecodeSafe: boolean | null = null
138+
139+
function canDecodeAvif(concurrency: number | null | undefined): boolean {
140+
if (_avifDecodeSafe === null) {
141+
let heifVersion: string | null = null
142+
try {
143+
// Custom libvips builds do not report dependency versions, so the
144+
// absence of a heif version is treated as unsafe.
145+
heifVersion = getSharp(concurrency).versions?.heif ?? null
146+
} catch {
147+
// Without sharp no AVIF can be decoded either, so treat it as unsafe
148+
// and let the image pass through unoptimized.
149+
heifVersion = null
150+
}
151+
_avifDecodeSafe = isAvifDecodeSafe(heifVersion)
152+
}
153+
return _avifDecodeSafe
154+
}
155+
115156
export interface ImageParamsResult {
116157
href: string
117158
isAbsolute: boolean
@@ -982,7 +1023,11 @@ export async function imageOptimizer(
9821023
upstreamEtag,
9831024
}
9841025
}
985-
if (BYPASS_TYPES.includes(upstreamType)) {
1026+
if (
1027+
BYPASS_TYPES.includes(upstreamType) ||
1028+
(upstreamType === AVIF &&
1029+
!canDecodeAvif(nextConfig.experimental.imgOptConcurrency))
1030+
) {
9861031
return {
9871032
buffer: upstreamBuffer,
9881033
contentType: upstreamType,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>hello world</p>
3+
}
1.02 KB
Binary file not shown.
30.5 KB
Loading
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import fs from 'fs-extra'
2+
import { join } from 'path'
3+
import { nextTestSetup } from 'e2e-utils'
4+
5+
const appDir = join(__dirname, 'app')
6+
7+
describe.each([
8+
// sharp 0.34.5 bundles libheif 1.20.2, which is affected by the AVIF
9+
// decoding vulnerabilities fixed in libheif 1.23.2.
10+
{
11+
sharp: '0.34.5',
12+
decodesAvif: false,
13+
},
14+
// sharp 0.35.4 is the first release bundling the patched libheif 1.23.2.
15+
{
16+
sharp: '0.35.4',
17+
decodesAvif: true,
18+
},
19+
])('with sharp $sharp', ({ sharp, decodesAvif }) => {
20+
const patchedSharpDeps = {
21+
sharp,
22+
'@img/sharp-darwin-arm64': sharp,
23+
'@img/sharp-darwin-x64': sharp,
24+
'@img/sharp-linux-arm64': sharp,
25+
'@img/sharp-linux-x64': sharp,
26+
'@img/sharp-win32-x64': sharp,
27+
}
28+
const { next } = nextTestSetup({
29+
files: appDir,
30+
dependencies: patchedSharpDeps,
31+
})
32+
33+
it('only optimizes avif input when the bundled libheif is patched', async () => {
34+
const query = new URLSearchParams({
35+
url: '/test.avif',
36+
w: '256',
37+
q: '75',
38+
})
39+
const res = await next.fetch(`/_next/image?${query}`, {
40+
headers: { accept: 'image/webp' },
41+
})
42+
43+
expect(res.status).toBe(200)
44+
if (decodesAvif) {
45+
expect(res.headers.get('content-type')).toBe('image/webp')
46+
} else {
47+
expect(res.headers.get('content-type')).toBe('image/avif')
48+
const source = await fs.readFile(join(appDir, 'public/test.avif'))
49+
expect(Buffer.from(await res.arrayBuffer()).equals(source)).toBe(true)
50+
}
51+
})
52+
53+
it('still optimizes other formats', async () => {
54+
const query = new URLSearchParams({
55+
url: '/test.png',
56+
w: '256',
57+
q: '75',
58+
})
59+
const res = await next.fetch(`/_next/image?${query}`, {
60+
headers: { accept: 'image/webp' },
61+
})
62+
63+
expect(res.status).toBe(200)
64+
expect(res.headers.get('content-type')).toBe('image/webp')
65+
})
66+
})

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

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
waitFor,
1717
} from 'next-test-utils'
1818
import isAnimated from 'next/dist/compiled/is-animated'
19+
import { getSharp, isAvifDecodeSafe } from 'next/dist/server/image-optimizer'
1920
import type { RequestInit } from 'node-fetch'
2021
import type { NextConfig } from 'next'
2122

@@ -100,6 +101,26 @@ export async function expectWidth(res, w, { expectAnimated = false } = {}) {
100101
expect(isAnimated(buffer)).toBe(expectAnimated)
101102
}
102103

104+
async function expectUnoptimizedImage(res, filename: string) {
105+
const buffer = Buffer.from(await res.arrayBuffer())
106+
const source = await fs.readFile(join(__dirname, '../app/public', filename))
107+
expect(buffer).toEqual(source)
108+
expect(res.headers.get('Content-Length')).toBe(
109+
Buffer.byteLength(source).toString()
110+
)
111+
}
112+
113+
// The image optimizer only decodes AVIF input when the installed sharp
114+
// bundles a patched libheif. The same check decides the expectations here so
115+
// CI jobs pinned to the vulnerable 0.34 line (Node 18) assert the bypass.
116+
function isAvifInputOptimizable(): boolean {
117+
try {
118+
return isAvifDecodeSafe(getSharp(null).versions?.heif ?? null)
119+
} catch {
120+
return false
121+
}
122+
}
123+
103124
export const cleanImagesDir = async (ctx: { imagesDir: string }) => {
104125
console.warn('Cleaning', ctx.imagesDir)
105126
await fs.remove(ctx.imagesDir)
@@ -530,16 +551,24 @@ export function runTests(ctx: RunTestsCtx) {
530551
const opts = { headers: { accept } }
531552
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
532553
expect(res.status).toBe(200)
533-
expect(res.headers.get('Content-Type')).toContain('image/jpeg')
534554
expect(res.headers.get('Cache-Control')).toBe(
535555
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
536556
)
537557
expect(res.headers.get('Vary')).toBe('Accept')
538558
expect(res.headers.get('etag')).toBeTruthy()
539-
expect(res.headers.get('Content-Disposition')).toBe(
540-
`${contentDispositionType}; filename="test.jpeg"`
541-
)
542-
await expectWidth(res, ctx.w)
559+
if (isAvifInputOptimizable()) {
560+
expect(res.headers.get('Content-Type')).toContain('image/jpeg')
561+
expect(res.headers.get('Content-Disposition')).toBe(
562+
`${contentDispositionType}; filename="test.jpeg"`
563+
)
564+
await expectWidth(res, ctx.w)
565+
} else {
566+
expect(res.headers.get('Content-Type')).toContain('image/avif')
567+
expect(res.headers.get('Content-Disposition')).toBe(
568+
`${contentDispositionType}; filename="test.avif"`
569+
)
570+
await expectUnoptimizedImage(res, 'test.avif')
571+
}
543572
})
544573

545574
it('should fail when url is missing', async () => {
@@ -810,16 +839,24 @@ export function runTests(ctx: RunTestsCtx) {
810839
const opts = { headers: { accept: 'image/webp' } }
811840
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
812841
expect(res.status).toBe(200)
813-
expect(res.headers.get('Content-Type')).toBe('image/webp')
814842
expect(res.headers.get('Cache-Control')).toBe(
815843
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
816844
)
817845
expect(res.headers.get('Vary')).toBe('Accept')
818846
expect(res.headers.get('etag')).toBeTruthy()
819-
expect(res.headers.get('Content-Disposition')).toBe(
820-
`${contentDispositionType}; filename="test.webp"`
821-
)
822-
await expectWidth(res, ctx.w)
847+
if (isAvifInputOptimizable()) {
848+
expect(res.headers.get('Content-Type')).toBe('image/webp')
849+
expect(res.headers.get('Content-Disposition')).toBe(
850+
`${contentDispositionType}; filename="test.webp"`
851+
)
852+
await expectWidth(res, ctx.w)
853+
} else {
854+
expect(res.headers.get('Content-Type')).toBe('image/avif')
855+
expect(res.headers.get('Content-Disposition')).toBe(
856+
`${contentDispositionType}; filename="test.avif"`
857+
)
858+
await expectUnoptimizedImage(res, 'test.avif')
859+
}
823860
})
824861

825862
it('should resize relative url and old Chrome accept header as webp', async () => {
@@ -877,7 +914,11 @@ export function runTests(ctx: RunTestsCtx) {
877914
expect(res.headers.get('Content-Disposition')).toBe(
878915
`${contentDispositionType}; filename="test.avif"`
879916
)
880-
await expectWidth(res, ctx.w)
917+
if (isAvifInputOptimizable()) {
918+
await expectWidth(res, ctx.w)
919+
} else {
920+
await expectUnoptimizedImage(res, 'test.avif')
921+
}
881922
})
882923

883924
it('should compress avif smaller than webp at q=100', async () => {

0 commit comments

Comments
 (0)