|
1 | 1 | import { describe, it, expect } from "vitest" |
2 | | -import { validateKycFile, KYC_MAX_FILE_SIZE } from "@/lib/security/kyc-file-validation" |
3 | | - |
4 | | -function jpegHeader(): Buffer { |
5 | | - return Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]) |
| 2 | +import { |
| 3 | + validateKycFile, |
| 4 | + KYC_MAX_FILE_SIZE, |
| 5 | + MAX_IMAGE_DIMENSION_PX, |
| 6 | + MAX_IMAGE_PIXELS, |
| 7 | +} from "@/lib/security/kyc-file-validation" |
| 8 | + |
| 9 | +function jpegHeader(width = 100, height = 100): Buffer { |
| 10 | + const soi = Buffer.from([0xff, 0xd8]) |
| 11 | + const app0 = Buffer.from([ |
| 12 | + 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, |
| 13 | + ]) |
| 14 | + const sof0 = Buffer.alloc(19) |
| 15 | + sof0[0] = 0xff |
| 16 | + sof0[1] = 0xc0 |
| 17 | + sof0.writeUInt16BE(17, 2) |
| 18 | + sof0[4] = 8 |
| 19 | + sof0.writeUInt16BE(height, 5) |
| 20 | + sof0.writeUInt16BE(width, 7) |
| 21 | + sof0[9] = 3 |
| 22 | + const eoi = Buffer.from([0xff, 0xd9]) |
| 23 | + return Buffer.concat([soi, app0, sof0, eoi]) |
6 | 24 | } |
7 | 25 |
|
8 | | -function pngHeader(): Buffer { |
9 | | - return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]) |
| 26 | +function pngHeader(width = 100, height = 100): Buffer { |
| 27 | + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) |
| 28 | + const length = Buffer.alloc(4) |
| 29 | + length.writeUInt32BE(13, 0) |
| 30 | + const chunkType = Buffer.from("IHDR") |
| 31 | + const data = Buffer.alloc(13) |
| 32 | + data.writeUInt32BE(width, 0) |
| 33 | + data.writeUInt32BE(height, 4) |
| 34 | + data[8] = 8 |
| 35 | + data[9] = 6 |
| 36 | + const crc = Buffer.alloc(4) |
| 37 | + return Buffer.concat([sig, length, chunkType, data, crc]) |
10 | 38 | } |
11 | 39 |
|
12 | | -function webpHeader(): Buffer { |
13 | | - const buf = Buffer.alloc(20) |
14 | | - buf.write("RIFF", 0) |
15 | | - buf.writeUInt32LE(100, 4) |
16 | | - buf.write("WEBP", 8) |
17 | | - buf.write("VP8 ", 12) |
18 | | - return buf |
| 40 | +function webpHeader(width = 100, height = 100): Buffer { |
| 41 | + const payload = Buffer.alloc(10) |
| 42 | + payload[3] = 0x9d |
| 43 | + payload[4] = 0x01 |
| 44 | + payload[5] = 0x2a |
| 45 | + payload.writeUInt16LE(width & 0x3fff, 6) |
| 46 | + payload.writeUInt16LE(height & 0x3fff, 8) |
| 47 | + const chunkHeader = Buffer.alloc(8) |
| 48 | + chunkHeader.write("VP8 ", 0) |
| 49 | + chunkHeader.writeUInt32LE(payload.length, 4) |
| 50 | + const webpChunk = Buffer.concat([chunkHeader, payload]) |
| 51 | + const riffHeader = Buffer.alloc(12) |
| 52 | + riffHeader.write("RIFF", 0) |
| 53 | + riffHeader.writeUInt32LE(4 + webpChunk.length, 4) |
| 54 | + riffHeader.write("WEBP", 8) |
| 55 | + return Buffer.concat([riffHeader, webpChunk]) |
19 | 56 | } |
20 | 57 |
|
21 | 58 | function pdfHeader(): Buffer { |
@@ -114,6 +151,41 @@ describe("validateKycFile", () => { |
114 | 151 | }) |
115 | 152 | }) |
116 | 153 |
|
| 154 | + describe("image dimension bounds", () => { |
| 155 | + it("rejects a truncated JPEG with no decodable dimensions", () => { |
| 156 | + const buffer = Buffer.from([0xff, 0xd8, 0xff]) |
| 157 | + const result = validateKycFile(buffer, "image/jpeg", "truncated.jpg") |
| 158 | + expect(result.valid).toBe(false) |
| 159 | + expect(result.errors.some((e) => e.includes("Unable to determine image dimensions"))).toBe(true) |
| 160 | + }) |
| 161 | + |
| 162 | + it("rejects an image wider than the maximum allowed dimension", () => { |
| 163 | + const buffer = jpegHeader(MAX_IMAGE_DIMENSION_PX + 1, 100) |
| 164 | + const result = validateKycFile(buffer, "image/jpeg", "wide.jpg") |
| 165 | + expect(result.valid).toBe(false) |
| 166 | + expect(result.errors.some((e) => e.includes("exceed the maximum allowed"))).toBe(true) |
| 167 | + }) |
| 168 | + |
| 169 | + it("rejects a decompression-bomb image whose pixel count exceeds the cap even though each side is within bounds", () => { |
| 170 | + const side = Math.floor(Math.sqrt(MAX_IMAGE_PIXELS)) + 1000 |
| 171 | + const buffer = pngHeader(side, side) |
| 172 | + const result = validateKycFile(buffer, "image/png", "bomb.png") |
| 173 | + expect(result.valid).toBe(false) |
| 174 | + expect(result.errors.some((e) => e.includes("exceed the maximum allowed"))).toBe(true) |
| 175 | + }) |
| 176 | + |
| 177 | + it("accepts a WebP image within bounds", () => { |
| 178 | + const buffer = webpHeader(200, 150) |
| 179 | + const result = validateKycFile(buffer, "image/webp", "photo.webp") |
| 180 | + expect(result.valid).toBe(true) |
| 181 | + }) |
| 182 | + |
| 183 | + it("does not require image dimensions for PDF files", () => { |
| 184 | + const result = validateKycFile(pdfHeader(), "application/pdf", "document.pdf") |
| 185 | + expect(result.valid).toBe(true) |
| 186 | + }) |
| 187 | + }) |
| 188 | + |
117 | 189 | describe("checksum", () => { |
118 | 190 | it("returns consistent checksum for same content", () => { |
119 | 191 | const buffer = pngHeader() |
|
0 commit comments