Skip to content

Commit 0dd8932

Browse files
authored
Merge pull request #226 from vjuliaife/fix/vehicle-upload-file-signature-validation
fix(upload): verify file signatures and reject image bombs for vehicle uploads
2 parents de61a73 + a17dcb6 commit 0dd8932

2 files changed

Lines changed: 197 additions & 13 deletions

File tree

__tests__/lib/security/kyc-file-validation.test.ts

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,58 @@
11
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])
624
}
725

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])
1038
}
1139

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])
1956
}
2057

2158
function pdfHeader(): Buffer {
@@ -114,6 +151,41 @@ describe("validateKycFile", () => {
114151
})
115152
})
116153

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+
117189
describe("checksum", () => {
118190
it("returns consistent checksum for same content", () => {
119191
const buffer = pngHeader()

lib/security/kyc-file-validation.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export const KYC_ALLOWED_MIME_TYPES = new Set([
1111

1212
export const KYC_ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".pdf"])
1313

14+
// Bounds shared by every caller (KYC documents and vehicle images) that decode
15+
// approved image formats. These limits guard against decompression-bomb style
16+
// inputs where a tiny file declares an enormous pixel grid.
17+
export const MAX_IMAGE_DIMENSION_PX = 12000
18+
export const MAX_IMAGE_PIXELS = 50_000_000
19+
20+
const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp"])
21+
1422
const SIGNATURES: { mime: string; extension: string[]; bytes: Buffer; offset: number }[] = [
1523
{
1624
mime: "image/jpeg",
@@ -68,6 +76,95 @@ function detectExtension(buffer: Buffer): string | null {
6876
return null
6977
}
7078

79+
type ImageDimensions = { width: number; height: number }
80+
81+
function getJpegDimensions(buffer: Buffer): ImageDimensions | null {
82+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null
83+
84+
let offset = 2
85+
while (offset + 4 <= buffer.length) {
86+
if (buffer[offset] !== 0xff) return null
87+
const marker = buffer[offset + 1]
88+
89+
// Markers without a length-prefixed payload.
90+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
91+
offset += 2
92+
continue
93+
}
94+
95+
const length = buffer.readUInt16BE(offset + 2)
96+
const isSOF =
97+
(marker >= 0xc0 && marker <= 0xc3) ||
98+
(marker >= 0xc5 && marker <= 0xc7) ||
99+
(marker >= 0xc9 && marker <= 0xcb) ||
100+
(marker >= 0xcd && marker <= 0xcf)
101+
102+
if (isSOF) {
103+
if (offset + 9 > buffer.length) return null
104+
const height = buffer.readUInt16BE(offset + 5)
105+
const width = buffer.readUInt16BE(offset + 7)
106+
if (!width || !height) return null
107+
return { width, height }
108+
}
109+
110+
if (marker === 0xda || length < 2) return null
111+
offset += 2 + length
112+
}
113+
return null
114+
}
115+
116+
function getPngDimensions(buffer: Buffer): ImageDimensions | null {
117+
if (buffer.length < 24) return null
118+
const width = buffer.readUInt32BE(16)
119+
const height = buffer.readUInt32BE(20)
120+
if (!width || !height) return null
121+
return { width, height }
122+
}
123+
124+
function getWebpDimensions(buffer: Buffer): ImageDimensions | null {
125+
if (buffer.length < 20) return null
126+
const chunkFourCC = buffer.subarray(12, 16).toString("ascii")
127+
128+
if (chunkFourCC === "VP8 ") {
129+
if (buffer.length < 30) return null
130+
if (!(buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a)) return null
131+
const width = buffer.readUInt16LE(26) & 0x3fff
132+
const height = buffer.readUInt16LE(28) & 0x3fff
133+
if (!width || !height) return null
134+
return { width, height }
135+
}
136+
137+
if (chunkFourCC === "VP8L") {
138+
if (buffer.length < 25 || buffer[20] !== 0x2f) return null
139+
const bits = buffer.readUInt32LE(21)
140+
const width = (bits & 0x3fff) + 1
141+
const height = ((bits >> 14) & 0x3fff) + 1
142+
return { width, height }
143+
}
144+
145+
if (chunkFourCC === "VP8X") {
146+
if (buffer.length < 30) return null
147+
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1
148+
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1
149+
return { width, height }
150+
}
151+
152+
return null
153+
}
154+
155+
function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
156+
switch (mimeType) {
157+
case "image/jpeg":
158+
return getJpegDimensions(buffer)
159+
case "image/png":
160+
return getPngDimensions(buffer)
161+
case "image/webp":
162+
return getWebpDimensions(buffer)
163+
default:
164+
return null
165+
}
166+
}
167+
71168
export type FileValidationResult = {
72169
valid: boolean
73170
errors: string[]
@@ -120,6 +217,21 @@ export function validateKycFile(
120217
)
121218
}
122219

220+
if (detectedMimeType && IMAGE_MIME_TYPES.has(detectedMimeType)) {
221+
const dimensions = getImageDimensions(buffer, detectedMimeType)
222+
if (!dimensions) {
223+
errors.push("Unable to determine image dimensions. File may be malformed, truncated, or corrupted.")
224+
} else if (
225+
dimensions.width > MAX_IMAGE_DIMENSION_PX ||
226+
dimensions.height > MAX_IMAGE_DIMENSION_PX ||
227+
dimensions.width * dimensions.height > MAX_IMAGE_PIXELS
228+
) {
229+
errors.push(
230+
`Image dimensions ${dimensions.width}x${dimensions.height} exceed the maximum allowed ${MAX_IMAGE_DIMENSION_PX}px per side or ${MAX_IMAGE_PIXELS} total pixels.`,
231+
)
232+
}
233+
}
234+
123235
return {
124236
valid: errors.length === 0,
125237
errors,

0 commit comments

Comments
 (0)