fix(1688): accept opaque CDN image responses

This commit is contained in:
WuYongJie 2026-09-15 10:03:05 +08:00
parent ed15789a81
commit b63b2fe974
2 changed files with 96 additions and 6 deletions

View File

@ -93,4 +93,60 @@ describe('product archive source image service', () => {
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
})
it('accepts an opaque binary response whose bytes sniff as an image', async () => {
// JPEG magic bytes: FF D8 FF ...
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
const service = new ProductArchiveSourceImageService(
repository('https://lf3-cm.example.com/obj/ecom-shop-material/jpeg_m_x_sx_1_www800-800'),
jest.fn().mockResolvedValue(
response({ contentType: 'application/octet-stream', bytes: jpegBytes }),
) as unknown as typeof fetch,
jest.fn().mockResolvedValue(['93.184.216.34']),
)
await expect(service.readByBarcode('BARCODE-1')).resolves.toEqual({
bytes: expect.objectContaining({ byteLength: jpegBytes.length }),
contentType: 'image/jpeg',
})
})
it('accepts an empty content type response whose bytes sniff as png', async () => {
const pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]
const service = new ProductArchiveSourceImageService(
repository('https://images.example.com/product'),
jest.fn().mockResolvedValue(response({ contentType: '', bytes: pngBytes })) as unknown as typeof fetch,
jest.fn().mockResolvedValue(['93.184.216.34']),
)
await expect(service.readByBarcode('BARCODE-1')).resolves.toEqual(
expect.objectContaining({ contentType: 'image/png' }),
)
})
it('rejects an opaque binary response whose bytes are not an image', async () => {
const service = new ProductArchiveSourceImageService(
repository('https://lf3-cm.example.com/obj/ecom-shop-material/jpeg_m_y_sx_2_www800-800'),
jest.fn().mockResolvedValue(
response({ contentType: 'application/octet-stream', bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] }),
) as unknown as typeof fetch,
jest.fn().mockResolvedValue(['93.184.216.34']),
)
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
})
it('never sniffs non-opaque content types back to images', async () => {
// text/html body starting with image magic must stay rejected
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
const service = new ProductArchiveSourceImageService(
repository('https://images.example.com/page.jpg'),
jest.fn().mockResolvedValue(
response({ contentType: 'text/html', bytes: jpegBytes }),
) as unknown as typeof fetch,
jest.fn().mockResolvedValue(['93.184.216.34']),
)
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
})
})

View File

@ -19,6 +19,35 @@ const SAFE_IMAGE_CONTENT_TYPES = new Set([
'image/png',
'image/webp',
])
// Some CDNs (e.g. Douyin ecombdstatic material objects) serve real images with
// an opaque content type; fall back to magic-byte sniffing for these only.
const OPAQUE_BINARY_CONTENT_TYPES = new Set([
'application/octet-stream',
'binary/octet-stream',
'',
])
function sniffImageContentType(bytes: ArrayBuffer): string | null {
const view = new Uint8Array(bytes)
if (view.length < 12) return null
if (view[0] === 0xff && view[1] === 0xd8 && view[2] === 0xff) return 'image/jpeg'
if (
view[0] === 0x89 && view[1] === 0x50 && view[2] === 0x4e && view[3] === 0x47 &&
view[4] === 0x0d && view[5] === 0x0a && view[6] === 0x1a && view[7] === 0x0a
) return 'image/png'
if (view[0] === 0x47 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x38) return 'image/gif'
if (
view[0] === 0x52 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x46 &&
view[8] === 0x57 && view[9] === 0x45 && view[10] === 0x42 && view[11] === 0x50
) return 'image/webp'
if (view[0] === 0x42 && view[1] === 0x4d) return 'image/bmp'
const brand = String.fromCharCode(view[8], view[9], view[10], view[11])
if (
view[4] === 0x66 && view[5] === 0x74 && view[6] === 0x79 && view[7] === 0x70 &&
(brand === 'avif' || brand === 'avis')
) return 'image/avif'
return null
}
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init)
const defaultResolveAddresses: ResolveAddresses = async (hostname) => {
@ -104,17 +133,22 @@ export class ProductArchiveSourceImageService {
}
if (!response.ok) return null
const contentType = (response.headers.get('content-type') ?? '')
.split(';')[0]
.trim()
.toLowerCase()
if (!SAFE_IMAGE_CONTENT_TYPES.has(contentType)) return null
const declaredLength = Number(response.headers.get('content-length') || '0')
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) return null
const bytes = await response.arrayBuffer()
if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) return null
const headerContentType = (response.headers.get('content-type') ?? '')
.split(';')[0]
.trim()
.toLowerCase()
const contentType = SAFE_IMAGE_CONTENT_TYPES.has(headerContentType)
? headerContentType
: OPAQUE_BINARY_CONTENT_TYPES.has(headerContentType)
? sniffImageContentType(bytes)
: null
if (!contentType) return null
return { bytes, contentType }
}
return null