fix: harden Web image admission
This commit is contained in:
@@ -1,93 +1,42 @@
|
||||
import sharp from 'sharp'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectImage } from '../src/image.ts'
|
||||
|
||||
function bytes(text: string): number[] {
|
||||
return [...Buffer.from(text, 'ascii')]
|
||||
async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
|
||||
const image = sharp({
|
||||
create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
|
||||
})
|
||||
return new Uint8Array(await image.toFormat(format).toBuffer())
|
||||
}
|
||||
|
||||
function webp(chunk: string, mutate: (data: Uint8Array) => void): Uint8Array {
|
||||
const data = new Uint8Array(30)
|
||||
data.set(bytes('RIFF'), 0)
|
||||
data.set([22, 0, 0, 0], 4)
|
||||
data.set(bytes('WEBP'), 8)
|
||||
data.set(bytes(chunk), 12)
|
||||
mutate(data)
|
||||
return data
|
||||
}
|
||||
|
||||
describe('raster header detection', () => {
|
||||
it('detects PNG dimensions', () => {
|
||||
const data = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
expect(detectImage(data)).toEqual({ mediaType: 'image/png', width: 1, height: 1 })
|
||||
describe('raster decoding', () => {
|
||||
it('decodes every supported format and its intrinsic dimensions', async () => {
|
||||
for (const [format, mediaType] of [
|
||||
['png', 'image/png'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['webp', 'image/webp'],
|
||||
['gif', 'image/gif'],
|
||||
] as const) {
|
||||
await expect(detectImage(await raster(format)))
|
||||
.resolves.toEqual({ mediaType, width: 3, height: 2 })
|
||||
}
|
||||
})
|
||||
|
||||
it('detects both GIF revisions and rejects zero dimensions', () => {
|
||||
expect(detectImage(Uint8Array.from([...bytes('GIF87a'), 3, 0, 2, 0])))
|
||||
.toEqual({ mediaType: 'image/gif', width: 3, height: 2 })
|
||||
expect(detectImage(Uint8Array.from([...bytes('GIF89a'), 4, 0, 5, 0])))
|
||||
.toEqual({ mediaType: 'image/gif', width: 4, height: 5 })
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 0, 0, 1, 0])))
|
||||
.toThrow(/positive/)
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 1, 0, 0, 0])))
|
||||
.toThrow(/positive/)
|
||||
it('rejects excess decoded pixels before decoding', async () => {
|
||||
await expect(detectImage(await raster('png'), 5))
|
||||
.rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
})
|
||||
|
||||
it('walks JPEG marker forms and reports malformed dimensions', () => {
|
||||
const sof = [0xff, 0xc0, 0, 7, 8, 0, 2, 0, 3]
|
||||
expect(detectImage(Uint8Array.from([0xff, 0xd8, ...sof])))
|
||||
.toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 })
|
||||
expect(detectImage(Uint8Array.from([
|
||||
0xff, 0xd8,
|
||||
0xe0, 0, 2,
|
||||
0x01,
|
||||
0xff, ...sof,
|
||||
]))).toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 })
|
||||
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xd9, 0, 0, 0])))
|
||||
.toThrow(/missing/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xff, 0xff, 0xff, 0xff])))
|
||||
.toThrow(/missing/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 1, 0])))
|
||||
.toThrow(/truncated/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 9, 0])))
|
||||
.toThrow(/truncated/)
|
||||
expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xc0, 0, 6, 0, 0, 0, 0])))
|
||||
.toThrow(/dimensions are truncated/)
|
||||
})
|
||||
|
||||
it('detects each WebP header and rejects truncated or unknown chunks', () => {
|
||||
expect(detectImage(webp('VP8X', (data) => {
|
||||
data.set([2, 0, 0], 24)
|
||||
data.set([3, 0, 0], 27)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 3, height: 4 })
|
||||
|
||||
expect(detectImage(webp('VP8L', (data) => {
|
||||
data[20] = 0x2f
|
||||
data.set([2, 0, 1, 0], 21)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 3, height: 5 })
|
||||
|
||||
expect(detectImage(webp('VP8 ', (data) => {
|
||||
data.set([0x9d, 0x01, 0x2a], 23)
|
||||
data.set([6, 0, 7, 0], 26)
|
||||
}))).toEqual({ mediaType: 'image/webp', width: 6, height: 7 })
|
||||
|
||||
const truncated = webp('VP8X', () => {})
|
||||
truncated[4] = 23
|
||||
expect(() => detectImage(truncated)).toThrow(/truncated/)
|
||||
expect(() => detectImage(webp('NOPE', () => {}))).toThrow(/dimensions are missing/)
|
||||
expect(() => detectImage(webp('VP8L', () => {}))).toThrow(/dimensions are missing/)
|
||||
expect(() => detectImage(webp('VP8 ', () => {}))).toThrow(/dimensions are missing/)
|
||||
})
|
||||
|
||||
it('rejects unrecognized bytes and near-miss signatures', () => {
|
||||
expect(() => detectImage(new Uint8Array(0))).toThrow(/Unsupported/)
|
||||
expect(() => detectImage(Uint8Array.from([...bytes('GIFxxa'), 1, 0, 1, 0])))
|
||||
.toThrow(/Unsupported/)
|
||||
const nearWebp = webp('VP8X', () => {})
|
||||
nearWebp[8] = 0
|
||||
expect(() => detectImage(nearWebp)).toThrow(/Unsupported/)
|
||||
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
|
||||
await expect(detectImage(Uint8Array.of(1, 2, 3)))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const unsupported = await sharp({
|
||||
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).tiff().toBuffer()
|
||||
await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const complete = await raster('png')
|
||||
const truncated = complete.subarray(0, 62)
|
||||
await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 })
|
||||
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,13 +42,13 @@ describe('local attachment service', () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) })
|
||||
.toThrow(/Unsupported or malformed image data/)
|
||||
await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }))
|
||||
.rejects.toThrow(/Unsupported or malformed image data/)
|
||||
const valid = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow()
|
||||
await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined()
|
||||
expect(existsSync(service.root)).toBe(false)
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
@@ -122,8 +123,9 @@ describe('local attachment store', () => {
|
||||
data: PNG, mediaType: 'image/png',
|
||||
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
|
||||
const wide = PNG.slice()
|
||||
wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16)
|
||||
const wide = new Uint8Array(await sharp({
|
||||
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).png().toBuffer())
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: wide, mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
|
||||
Reference in New Issue
Block a user