feat: enhance image upload validation across the application (#22766)

* feat: enhance image upload validation across the application

* Patch improvements.

* refactor: streamline avatar upload error handling in updateProfile handler

* refactor: extract image validation logic into a separate module for reuse in BannerUploader and ImageUploader

* fix: reset file input value on validation failure in image uploaders

* fix: update localization key for image file upload error message

* fix: update HTML content validation in image uploader to check for additional byte

* Code Quality improvements

* add : unit tests.

* fix : fixing some more issues.

* fix : some import fixes

* fix : fixing type-check issues

* fix : some more import fixes.

* fix : removing Duplicate max-file-size constant

* refactor: enhance base64 validation with regex pattern

* refactor: centralize MAX_IMAGE_FILE_SIZE and fix SVG checks.

* fix : some toast fixes.

* fixing the size of the banner.

* fix: update accepted image formats in BannerUploader and ImageUploader components

* fix

* coderabbit suggestions addressed.

* addressed coderrabit comment

* refactor: implement generic i18n error messages with interpolation

- Add generic 'unsupported_file_type' translation key with {{type}} interpolation
- Replace hardcoded file type error messages with reusable i18n pattern
- Update imageValidation interfaces to support errorKey and errorParams
- Refactor server-side and client-side validation to use new pattern
- Remove individual translation keys for each file type (PDF, HTML, Script, ZIP, Executable)
- Add new translation keys for other validation errors (SVG, base64, empty data, etc.)
- Type-safe error handling with proper interpolation support

Benefits:
- Single reusable translation pattern reduces duplication
- Easier to maintain and localize
- Consistent error messaging across file types

* feat: add MAX_BANNER_SIZE constant and update file size validation

- Add MAX_BANNER_SIZE constant (5MB) to shared constants file
- Update BannerUploader to use shared constant instead of inline value
- Update ImageUploader to handle new errorKey/errorParams pattern
- Maintain backward compatibility with existing error handling

Note: Pre-existing linting warnings in constants.ts and BannerUploader.tsx
are unrelated to these changes and were present before this refactor.

* adressed volnei's suggestions.

* test: update image validation tests for new errorKey/errorParams pattern

- Update all dangerous file type tests to expect errorKey and errorParams instead of hardcoded error messages
- Add comprehensive tests for new error format validation pattern
- Add tests for backward compatibility with error field
- Add tests for MAX_BANNER_SIZE constant integration
- Verify that unsupported file types now return generic 'unsupported_file_type' key with type interpolation
- Ensure all existing functionality continues to work with enhanced error handling

All 42 tests passing ✅

* test: update server-side image validation tests for new error format

- Update all dangerous file type tests to expect errorKey and errorParams
- Change hardcoded error messages to i18n keys (unsupported_file_type, svg_contains_dangerous_content, etc.)
- Update edge case tests to use new error keys (empty_image_data, invalid_base64_format, unrecognized_image_format)
- Add comprehensive tests for new error format validation pattern
- Add tests for backward compatibility with error field
- Verify that unsupported file types return generic 'unsupported_file_type' key with type interpolation

All 26 server-side tests passing ✅
Complements client-side test updates for complete test coverage

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
This commit is contained in:
Devanshu Sharma
2025-09-04 18:22:36 +05:30
committed by GitHub
co-authored by Anik Dhabal Babu Udit Takkar unknown
parent 7bf5d9cf64
commit 5acdb208f5
16 changed files with 1128 additions and 64 deletions
+21 -4
View File
@@ -3,6 +3,8 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -124,10 +126,25 @@ export async function patchHandler(req: NextApiRequest) {
}
if (avatar) {
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
});
const validation = validateBase64Image(avatar);
if (!validation.isValid) {
throw new HttpError({
statusCode: 400,
message: `Invalid avatar image: ${validation.error}`,
});
}
try {
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await resizeBase64Image(avatar),
});
} catch (error) {
throw new HttpError({
statusCode: 400,
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
}
const data = await prisma.user.update({
+12
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { AVATAR_FALLBACK, WEBAPP_URL } from "@calcom/lib/constants";
import { convertSvgToPng } from "@calcom/lib/server/imageUtils";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import prisma from "@calcom/prisma";
const querySchema = z.object({
@@ -46,6 +47,12 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
},
});
const validation = validateBase64Image(data);
if (!validation.isValid) {
const url = new URL(AVATAR_FALLBACK, WEBAPP_URL).toString();
return NextResponse.redirect(url, 302);
}
// Convert SVG to PNG if needed and update the database
if (data.startsWith("data:image/svg+xml;base64,")) {
const pngData = await convertSvgToPng(data);
@@ -72,6 +79,11 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
"Content-Type": "image/png",
"Content-Length": imageResp.length.toString(),
"Cache-Control": "max-age=86400",
// Security headers to prevent XSS
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'none'; img-src 'self'",
},
status: 200,
});
@@ -3581,6 +3581,14 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"unsupported_file_type": "{{type}} files cannot be uploaded as images",
"only_image_files_allowed": "Only image files are allowed",
"failed_to_validate_image_file": "Failed to validate image file",
"invalid_image_file_format": "Invalid image file format",
"svg_contains_dangerous_content": "SVG contains potentially dangerous content",
"unrecognized_image_format": "Unrecognized image format or invalid file",
"invalid_base64_format": "Invalid base64 format",
"empty_image_data": "Empty image data",
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
@@ -3,6 +3,7 @@ import type { OAuth2Client } from "googleapis-common";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
@@ -16,12 +17,17 @@ export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userI
return;
}
// Handle base64 data
if (
avatarUrl.startsWith("data:image/png;base64,") ||
avatarUrl.startsWith("data:image/jpeg;base64,") ||
avatarUrl.startsWith("data:image/jpg;base64,")
) {
const validation = validateBase64Image(avatarUrl);
if (!validation.isValid) {
logger.error(`Invalid avatar image from Google OAuth: ${validation.error}`);
return;
}
const resizedAvatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(avatarUrl),
userId,
+3
View File
@@ -68,6 +68,9 @@ export const MAX_EVENT_DURATION_MINUTES = 1440;
/** Minimum duration allowed for an event in minutes */
export const MIN_EVENT_DURATION_MINUTES = 1;
/** Maximum file size allowed for banner uploads in bytes (5MB) */
export const MAX_BANNER_SIZE = 5 * 1024 * 1024;
export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED;
export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS =
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared image validation constants and utilities
* Used by both server-side and client-side validation modules
*/
/**
* Magic numbers (file signatures) for different file types
*/
export const FILE_SIGNATURES = {
PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
JPEG_FF_D8_FF: [0xff, 0xd8, 0xff],
GIF87a: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
GIF89a: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
WEBP: [0x52, 0x49, 0x46, 0x46],
WEBP_SIGNATURE: [0x57, 0x45, 0x42, 0x50],
BMP: [0x42, 0x4d],
ICO: [0x00, 0x00, 0x01, 0x00],
SVG: [0x3c, 0x3f, 0x78, 0x6d, 0x6c],
SVG_DIRECT: [0x3c, 0x73, 0x76, 0x67],
PDF: [0x25, 0x50, 0x44, 0x46],
HTML: [0x3c, 0x21, 0x44, 0x4f, 0x43, 0x54, 0x59, 0x50, 0x45],
HTML_TAG: [0x3c, 0x68, 0x74, 0x6d, 0x6c],
SCRIPT_TAG: [0x3c, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74],
ZIP: [0x50, 0x4b, 0x03, 0x04],
EXECUTABLE: [0x4d, 0x5a],
} as const;
/**
* Check if bytes match a signature
*/
export function matchesSignature(data: Uint8Array, signature: readonly number[]): boolean {
if (data.length < signature.length) return false;
return signature.every((byte, index) => data[index] === byte);
}
/**
* SVG content validation patterns
*/
export const DANGEROUS_SVG_PATTERNS = [
"<script",
"javascript:",
"onload=",
"onclick=",
"onmouseover=",
"onmouseout=",
"onfocus=",
"onblur=",
] as const;
/**
* Validate SVG content for dangerous patterns
*/
export function containsDangerousSVGContent(content: string): boolean {
return DANGEROUS_SVG_PATTERNS.some((pattern) => content.includes(pattern));
}
/**
* Base64 regex pattern that matches valid base64 strings
* - Matches complete base64 groups of 4 characters
* - Handles proper padding with = characters
* - Supports both padded and unpadded forms correctly
*/
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=)?$/;
/**
* Validate base64 string format using strict regex
*/
export function isValidBase64(str: string): boolean {
return BASE64_REGEX.test(str);
}
+11
View File
@@ -3,8 +3,14 @@ import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
import { convertSvgToPng } from "./imageUtils";
import { validateBase64Image } from "./imageValidation";
export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
const validation = validateBase64Image(data);
if (!validation.isValid) {
throw new Error(`Invalid image data: ${validation.error}`);
}
const objectKey = uuidv4();
const processedData = await convertSvgToPng(data);
@@ -40,6 +46,11 @@ export const uploadLogo = async ({
logo: string;
isBanner?: boolean;
}): Promise<string> => {
const validation = validateBase64Image(data);
if (!validation.isValid) {
throw new Error(`Invalid image data: ${validation.error}`);
}
const objectKey = uuidv4();
const processedData = await convertSvgToPng(data);
+320
View File
@@ -0,0 +1,320 @@
import { describe, it, expect } from "vitest";
import { FILE_SIGNATURES } from "@calcom/lib/imageValidationConstants";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
describe("validateBase64Image", () => {
const createBase64Data = (bytes: number[], mimeType = "image/png") => {
const buffer = Buffer.from(bytes);
return `data:${mimeType};base64,${buffer.toString("base64")}`;
};
describe("Valid image formats", () => {
it("should validate PNG images", () => {
const pngBytes = [...FILE_SIGNATURES.PNG, ...Array(10).fill(0)];
const base64Data = createBase64Data(pngBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("PNG");
expect(result.error).toBeUndefined();
});
it("should validate JPEG images", () => {
const jpegBytes = [...FILE_SIGNATURES.JPEG_FF_D8_FF, 0xe0, ...Array(10).fill(0)];
const base64Data = createBase64Data(jpegBytes, "image/jpeg");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("JPEG");
});
it("should validate GIF87a images", () => {
const gifBytes = [...FILE_SIGNATURES.GIF87a, ...Array(10).fill(0)];
const base64Data = createBase64Data(gifBytes, "image/gif");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("GIF");
});
it("should validate GIF89a images", () => {
const gifBytes = [...FILE_SIGNATURES.GIF89a, ...Array(10).fill(0)];
const base64Data = createBase64Data(gifBytes, "image/gif");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("GIF");
});
it("should validate WEBP images", () => {
const webpBytes = [
...FILE_SIGNATURES.WEBP,
0x00,
0x00,
0x00,
0x00,
...FILE_SIGNATURES.WEBP_SIGNATURE,
...Array(10).fill(0),
];
const base64Data = createBase64Data(webpBytes, "image/webp");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("WEBP");
});
it("should validate BMP images", () => {
const bmpBytes = [...FILE_SIGNATURES.BMP, ...Array(10).fill(0)];
const base64Data = createBase64Data(bmpBytes, "image/bmp");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("BMP");
});
it("should validate ICO images", () => {
const icoBytes = [...FILE_SIGNATURES.ICO, ...Array(10).fill(0)];
const base64Data = createBase64Data(icoBytes, "image/x-icon");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("ICO");
});
it("should validate safe SVG images", () => {
const svgContent =
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("SVG");
});
it("should validate SVG with direct <svg> tag", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("SVG");
});
});
describe("Dangerous file types", () => {
it("should reject PDF files", () => {
const pdfBytes = [...FILE_SIGNATURES.PDF, 0x2d, 0x31, 0x2e, 0x34];
const base64Data = createBase64Data(pdfBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("PDF");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should reject HTML files with DOCTYPE", () => {
const htmlBytes = [...FILE_SIGNATURES.HTML];
const base64Data = createBase64Data(htmlBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("HTML");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "HTML" });
});
it("should reject HTML files with <html> tag", () => {
const htmlBytes = [...FILE_SIGNATURES.HTML_TAG, 0x3e];
const base64Data = createBase64Data(htmlBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("HTML");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "HTML" });
});
it("should reject script files", () => {
const scriptBytes = [...FILE_SIGNATURES.SCRIPT_TAG];
const base64Data = createBase64Data(scriptBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Script");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Script" });
});
it("should reject ZIP files", () => {
const zipBytes = [...FILE_SIGNATURES.ZIP];
const base64Data = createBase64Data(zipBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("ZIP");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "ZIP" });
});
it("should reject executable files", () => {
const exeBytes = [...FILE_SIGNATURES.EXECUTABLE];
const base64Data = createBase64Data(exeBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Executable");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Executable" });
});
it("should reject SVG with script content", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
it("should reject SVG with javascript: URLs", () => {
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)">link</a></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
it("should reject SVG with onload handlers", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
});
describe("Error format validation", () => {
it("should return errorKey and errorParams for unsupported file types", () => {
const pdfBytes = [...FILE_SIGNATURES.PDF, 0x2d, 0x31, 0x2e, 0x34];
const base64Data = createBase64Data(pdfBytes);
const result = validateBase64Image(base64Data);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("errorKey");
expect(result).toHaveProperty("errorParams");
expect(result).toHaveProperty("detectedFormat");
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should return error field for non-interpolated errors", () => {
const base64Data = "data:image/png;base64,";
const result = validateBase64Image(base64Data);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("error");
expect(result.isValid).toBe(false);
expect(result.error).toBe("empty_image_data");
expect(result.errorKey).toBeUndefined();
expect(result.errorParams).toBeUndefined();
});
});
describe("Edge cases", () => {
it("should handle empty image data", () => {
const base64Data = "data:image/png;base64,";
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.error).toBe("empty_image_data");
});
it("should handle malformed base64", () => {
const base64Data = "data:image/png;base64,invalid-base64!@#$";
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_base64_format");
});
it("should handle unrecognized file format", () => {
const unknownBytes = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
const base64Data = createBase64Data(unknownBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle files too short for magic number detection", () => {
const shortBytes = [0x89];
const base64Data = createBase64Data(shortBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle WEBP files without proper WEBP signature", () => {
const fakeWebpBytes = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x46, 0x41, 0x4b, 0x45];
const base64Data = createBase64Data(fakeWebpBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle base64 data without proper data URL prefix", () => {
const pngBytes = [...FILE_SIGNATURES.PNG];
const buffer = Buffer.from(pngBytes);
const base64Data = buffer.toString("base64");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("PNG");
});
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Server-side image validation utility with magic number checking to prevent XSS attacks
* This prevents malicious files (like PDFs with embedded JavaScript) from being uploaded as images
*/
import {
FILE_SIGNATURES,
matchesSignature,
containsDangerousSVGContent,
isValidBase64,
} from "../imageValidationConstants";
export interface ImageValidationResult {
isValid: boolean;
error?: string;
errorKey?: string;
errorParams?: Record<string, string | number>;
detectedFormat?: string;
}
/**
* Validate base64 image data for magic numbers and content
*/
export function validateBase64Image(base64Data: string): ImageValidationResult {
try {
const base64Content = base64Data.replace(/^data:image\/[^;]+;base64,/, "");
if (!isValidBase64(base64Content)) {
return { isValid: false, error: "invalid_base64_format" };
}
const buffer = Buffer.from(base64Content, "base64");
const uint8Array = new Uint8Array(buffer);
if (uint8Array.length === 0) {
return { isValid: false, error: "empty_image_data" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.PDF)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "PDF" },
detectedFormat: "PDF",
};
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.HTML) ||
matchesSignature(uint8Array, FILE_SIGNATURES.HTML_TAG)
) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "HTML" },
detectedFormat: "HTML",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.SCRIPT_TAG)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "Script" },
detectedFormat: "Script",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.ZIP)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "ZIP" },
detectedFormat: "ZIP",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.EXECUTABLE)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "Executable" },
detectedFormat: "Executable",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.PNG)) {
return { isValid: true, detectedFormat: "PNG" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.JPEG_FF_D8_FF)) {
return { isValid: true, detectedFormat: "JPEG" };
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.GIF87a) ||
matchesSignature(uint8Array, FILE_SIGNATURES.GIF89a)
) {
return { isValid: true, detectedFormat: "GIF" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.BMP)) {
return { isValid: true, detectedFormat: "BMP" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.ICO)) {
return { isValid: true, detectedFormat: "ICO" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.WEBP) && uint8Array.length >= 12) {
if (matchesSignature(uint8Array.slice(8), FILE_SIGNATURES.WEBP_SIGNATURE)) {
return { isValid: true, detectedFormat: "WEBP" };
}
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.SVG) ||
matchesSignature(uint8Array, FILE_SIGNATURES.SVG_DIRECT)
) {
const textContent = buffer.toString("utf8");
if (containsDangerousSVGContent(textContent)) {
return { isValid: false, error: "svg_contains_dangerous_content", detectedFormat: "SVG" };
}
return { isValid: true, detectedFormat: "SVG" };
}
return { isValid: false, error: "unrecognized_image_format", detectedFormat: "Unknown" };
} catch (error) {
return { isValid: false, error: "failed_to_validate_image_file" };
}
}
@@ -13,6 +13,7 @@ import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries/teams";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
@@ -151,17 +152,26 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
}
// if defined AND a base 64 string, upload and update the avatar URL
if (
input.avatarUrl &&
(input.avatarUrl.startsWith("data:image/png;base64,") ||
input.avatarUrl.startsWith("data:image/jpeg;base64,") ||
input.avatarUrl.startsWith("data:image/jpg;base64,"))
) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
if (input.avatarUrl) {
const validation = validateBase64Image(input.avatarUrl);
if (!validation.isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid avatar image: ${validation.error}`,
});
}
try {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
}
if (input.completedOnboarding) {
@@ -15,6 +15,8 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TUpdateInputSchema } from "./update.schema";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
type UpdateOptions = {
ctx: {
@@ -186,32 +188,76 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (
input.banner &&
(input.banner.startsWith("data:image/png;base64,") ||
if (input.banner) {
const validation = validateBase64Image(input.banner);
if (!validation.isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid banner image: ${validation.error}`,
});
}
if (
input.banner.startsWith("data:image/png;base64,") ||
input.banner.startsWith("data:image/jpeg;base64,") ||
input.banner.startsWith("data:image/jpg;base64,"))
) {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
teamId: currentOrgId,
isBanner: true,
});
input.banner.startsWith("data:image/jpg;base64,") ||
input.banner.startsWith("data:image/svg+xml;base64,")
) {
try {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
teamId: currentOrgId,
isBanner: true,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to upload banner",
});
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unsupported banner format. Please use PNG, JPEG, or SVG.",
});
}
} else {
data.bannerUrl = null;
}
if (
input.logoUrl &&
(input.logoUrl.startsWith("data:image/png;base64,") ||
if (input.logoUrl) {
const validation = validateBase64Image(input.logoUrl);
if (!validation.isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid logo image: ${validation.error}`,
});
}
if (
input.logoUrl.startsWith("data:image/png;base64,") ||
input.logoUrl.startsWith("data:image/jpeg;base64,") ||
input.logoUrl.startsWith("data:image/jpg;base64,"))
) {
data.logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,
});
input.logoUrl.startsWith("data:image/jpg;base64,") ||
input.logoUrl.startsWith("data:image/svg+xml;base64,")
) {
try {
data.logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to upload logo",
});
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unsupported logo format. Please use PNG, JPEG, or SVG.",
});
}
}
if (input.slug) {
@@ -220,12 +266,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */
!prevOrganisation.slug
) {
// Save it on the metadata so we can use it later
data.metadata = mergeMetadata({ requestedSlug: input.slug });
} else {
data.slug = input.slug;
data.metadata = mergeMetadata({
// If we save slug, we don't need the requestedSlug anymore
requestedSlug: undefined,
...input.metadata,
});
@@ -6,6 +6,7 @@ import { RoleManagementError } from "@calcom/features/pbac/domain/errors/role-ma
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { prisma } from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/enums";
@@ -116,20 +117,41 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
timeZone: input.timeZone,
};
if (
input.avatar &&
(input.avatar.startsWith("data:image/png;base64,") ||
if (input.avatar) {
const validation = validateBase64Image(input.avatar);
if (!validation.isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid avatar image: ${validation.error}`,
});
}
if (
input.avatar.startsWith("data:image/png;base64,") ||
input.avatar.startsWith("data:image/jpeg;base64,") ||
input.avatar.startsWith("data:image/jpg;base64,"))
) {
const avatar = await resizeBase64Image(input.avatar);
data.avatarUrl = await uploadAvatar({
avatar,
userId: user.id,
});
}
if (input.avatar === "") {
data.avatarUrl = null;
input.avatar.startsWith("data:image/jpg;base64,") ||
input.avatar.startsWith("data:image/svg+xml;base64,")
) {
try {
const avatar = await resizeBase64Image(input.avatar);
data.avatarUrl = await uploadAvatar({
avatar,
userId: user.id,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
} else if (input.avatar === "") {
data.avatarUrl = null;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unsupported image format. Please use PNG, JPEG, or SVG.",
});
}
}
// Update user
@@ -4,6 +4,7 @@ import { useCallback, useState, useEffect } from "react";
import Cropper from "react-easy-crop";
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
import { MAX_BANNER_SIZE } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonColor } from "../button";
@@ -12,6 +13,7 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from
import { showToast } from "../toast";
import { useFileReader, createImage, Slider } from "./Common";
import type { FileEvent, Area } from "./Common";
import { validateImageFile } from "./imageValidation";
type BannerUploaderProps = {
id: string;
@@ -90,14 +92,21 @@ export default function BannerUploader({
return;
}
const limit = 5 * 1000000; // max limit 5mb
const file = e.target.files[0];
if (file.size > limit) {
showToast(t("image_size_limit_exceed"), "error");
} else {
setFile(file);
const validation = await validateImageFile(file, MAX_BANNER_SIZE);
if (!validation.isValid) {
if (validation.errorKey && validation.errorParams) {
showToast(t(validation.errorKey, validation.errorParams) as string, "error");
} else if (validation.error) {
showToast(t(validation.error), "error");
}
e.target.value = "";
return;
}
setFile(file);
};
const showCroppedImage = useCallback(
@@ -175,7 +184,7 @@ export default function BannerUploader({
name={id}
placeholder={t("upload_image")}
className="text-default pointer-events-none absolute mt-4 opacity-0 "
accept="image/*"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/bmp,image/x-icon,image/svg+xml"
/>
{t("choose_a_file")}
</label>
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import Cropper from "react-easy-crop";
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
import { MAX_BANNER_SIZE } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonColor, ButtonProps } from "../button";
@@ -12,6 +13,7 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from
import { showToast } from "../toast";
import { useFileReader, createImage, Slider } from "./Common";
import type { FileEvent, Area } from "./Common";
import { validateImageFile } from "./imageValidation";
const MAX_IMAGE_SIZE = 512;
@@ -88,19 +90,26 @@ export default function ImageUploader({
method: "readAsDataURL",
});
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
const onInputFile = async (e: FileEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const limit = 5 * 1000000; // max limit 5mb
const file = e.target.files[0];
if (file.size > limit) {
showToast(t("image_size_limit_exceed"), "error");
} else {
setFile(file);
const validation = await validateImageFile(file, MAX_BANNER_SIZE);
if (!validation.isValid) {
if (validation.errorKey && validation.errorParams) {
showToast(t(validation.errorKey, validation.errorParams) as string, "error");
} else if (validation.error) {
showToast(t(validation.error), "error");
}
e.target.value = "";
return;
}
setFile(file);
};
const showCroppedImage = useCallback(
@@ -163,7 +172,7 @@ export default function ImageUploader({
name={id}
placeholder={t("upload_image")}
className="text-default pointer-events-none absolute mt-4 opacity-0 "
accept="image/*"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/bmp,image/x-icon,image/svg+xml"
/>
{t("choose_a_file")}
</label>
@@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MAX_BANNER_SIZE } from "@calcom/lib/constants";
import { validateImageFile, MAX_IMAGE_FILE_SIZE } from "./imageValidation";
describe("validateImageFile", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockFile = (bytes: number[], name = "test.png", type = "image/png", size?: number) => {
const buffer = new ArrayBuffer(bytes.length);
const view = new Uint8Array(buffer);
bytes.forEach((byte, index) => {
view[index] = byte;
});
const file = {
name,
type,
size: size !== undefined ? size : bytes.length,
arrayBuffer: vi.fn().mockResolvedValue(buffer),
} as unknown as File;
return file;
};
describe("File size validation", () => {
it("should reject files larger than MAX_IMAGE_FILE_SIZE", async () => {
const largeFile = createMockFile([0x89, 0x50, 0x4e, 0x47], "large.png", "image/png", 6 * 1024 * 1024);
const result = await validateImageFile(largeFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.error).toBe("image_size_limit_exceed");
});
it("should accept files within size limit", async () => {
const validFile = createMockFile(
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
"valid.png",
"image/png",
1024
);
const result = await validateImageFile(validFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should respect custom maxFileSize parameter", async () => {
const customMaxSize = 2 * 1024 * 1024; // 2MB
const largeFile = createMockFile([0x89, 0x50, 0x4e, 0x47], "large.png", "image/png", 3 * 1024 * 1024);
const result = await validateImageFile(largeFile, customMaxSize);
expect(result.isValid).toBe(false);
expect(result.error).toBe("image_size_limit_exceed");
});
});
describe("Magic number validation - dangerous files", () => {
it("should reject PDF files", async () => {
const pdfFile = createMockFile([0x25, 0x50, 0x44, 0x46], "fake.png", "image/png");
const result = await validateImageFile(pdfFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should reject HTML files", async () => {
const htmlFile = createMockFile(
[0x3c, 0x21, 0x44, 0x4f, 0x43, 0x54, 0x59, 0x50, 0x45, 0x20],
"fake.png",
"image/png"
);
const result = await validateImageFile(htmlFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "HTML" });
});
it("should reject script files", async () => {
const scriptFile = createMockFile([0x3c, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74], "fake.png", "image/png");
const result = await validateImageFile(scriptFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Script" });
});
it("should reject ZIP files", async () => {
const zipFile = createMockFile([0x50, 0x4b, 0x03, 0x04], "fake.png", "image/png");
const result = await validateImageFile(zipFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "ZIP" });
});
it("should reject executable files", async () => {
const exeFile = createMockFile([0x4d, 0x5a], "fake.png", "image/png");
const result = await validateImageFile(exeFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Executable" });
});
});
describe("Magic number validation - valid images", () => {
it("should accept PNG files", async () => {
const pngFile = createMockFile(
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
"test.png",
"image/png"
);
const result = await validateImageFile(pngFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept JPEG files", async () => {
const jpegFile = createMockFile([0xff, 0xd8, 0xff, 0xe0], "test.jpg", "image/jpeg");
const result = await validateImageFile(jpegFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept GIF files", async () => {
const gifFile = createMockFile([0x47, 0x49, 0x46, 0x38, 0x37, 0x61], "test.gif", "image/gif");
const result = await validateImageFile(gifFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept WEBP files", async () => {
const webpFile = createMockFile(
[0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50],
"test.webp",
"image/webp"
);
const result = await validateImageFile(webpFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept BMP files", async () => {
const bmpFile = createMockFile([0x42, 0x4d], "test.bmp", "image/bmp");
const result = await validateImageFile(bmpFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should accept ICO files", async () => {
const icoFile = createMockFile([0x00, 0x00, 0x01, 0x00], "test.ico", "image/x-icon");
const result = await validateImageFile(icoFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(true);
expect(result.error).toBeUndefined();
});
});
describe("New error format validation", () => {
it("should return errorKey and errorParams for unsupported file types", async () => {
const pdfFile = createMockFile([0x25, 0x50, 0x44, 0x46], "fake.png", "image/png");
const result = await validateImageFile(pdfFile, MAX_IMAGE_FILE_SIZE);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("errorKey");
expect(result).toHaveProperty("errorParams");
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should return error field for backward compatibility when not using errorKey pattern", async () => {
const invalidFile = createMockFile([0x00, 0x01, 0x02, 0x03], "invalid.png", "image/png");
const result = await validateImageFile(invalidFile, MAX_IMAGE_FILE_SIZE);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("error");
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_image_file_format");
expect(result.errorKey).toBeUndefined();
expect(result.errorParams).toBeUndefined();
});
});
describe("Constants integration", () => {
it("should use MAX_BANNER_SIZE constant correctly", () => {
expect(MAX_IMAGE_FILE_SIZE).toBe(MAX_BANNER_SIZE);
expect(MAX_IMAGE_FILE_SIZE).toBe(5 * 1024 * 1024); // 5MB
});
});
describe("Edge cases", () => {
it("should reject files with invalid image format", async () => {
const invalidFile = createMockFile([0x00, 0x01, 0x02, 0x03], "invalid.png", "image/png");
const result = await validateImageFile(invalidFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_image_file_format");
});
it("should handle file reading errors gracefully", async () => {
const errorFile = createMockFile([0x89, 0x50, 0x4e, 0x47], "error.png", "image/png");
const mockArrayBuffer = vi.fn().mockRejectedValue(new Error("File read error"));
Object.defineProperty(errorFile, "arrayBuffer", { value: mockArrayBuffer });
const result = await validateImageFile(errorFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.error).toBe("failed_to_validate_image_file");
});
it("should handle empty files", async () => {
const emptyFile = createMockFile([], "empty.png", "image/png");
const result = await validateImageFile(emptyFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_image_file_format");
});
it("should handle files with insufficient bytes for magic number detection", async () => {
const shortFile = createMockFile([0x89], "short.png", "image/png");
const result = await validateImageFile(shortFile, MAX_IMAGE_FILE_SIZE);
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_image_file_format");
});
});
});
@@ -0,0 +1,131 @@
import { MAX_BANNER_SIZE } from "@calcom/lib/constants";
import {
FILE_SIGNATURES,
matchesSignature,
containsDangerousSVGContent,
} from "@calcom/lib/imageValidationConstants";
export const MAX_IMAGE_FILE_SIZE = MAX_BANNER_SIZE;
const stripBomAndWhitespace = (data: Uint8Array): Uint8Array => {
let start = 0;
if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
start = 3;
}
while (start < data.length) {
const byte = data[start];
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d || byte === 0x0c || byte === 0x0b) {
start++;
} else {
break;
}
}
return data.slice(start);
};
const checkTextBasedDangerousSignature = (
data: Uint8Array
): { key: string; params: Record<string, string | number> } | null => {
const text = new TextDecoder("utf-8", { fatal: false }).decode(data).toLowerCase();
if (text.includes("<!doctype") || text.includes("<html") || matchesSignature(data, FILE_SIGNATURES.HTML)) {
return { key: "unsupported_file_type", params: { type: "HTML" } };
}
if (text.includes("<script") || matchesSignature(data, FILE_SIGNATURES.SCRIPT_TAG)) {
return { key: "unsupported_file_type", params: { type: "Script" } };
}
return null;
};
const createTrimmedTextHead = (text: string, maxLength = 512): Uint8Array => {
let trimmedText = text.replace(/^[\uFEFF\s]+/, "");
if (trimmedText.length > maxLength) {
trimmedText = trimmedText.substring(0, maxLength);
}
return new TextEncoder().encode(trimmedText);
};
export const validateImageFile = async (
file: File,
maxFileSize: number = MAX_IMAGE_FILE_SIZE
): Promise<{
isValid: boolean;
error?: string;
errorKey?: string;
errorParams?: Record<string, string | number>;
}> => {
if (file.size > maxFileSize) {
return { isValid: false, error: "image_size_limit_exceed" };
}
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
if (uint8Array.length === 0) {
return { isValid: false, error: "invalid_image_file_format" };
}
const strippedData = stripBomAndWhitespace(uint8Array);
if (strippedData.length === 0) {
return { isValid: false, error: "invalid_image_file_format" };
}
const trimmedHead = strippedData.slice(0, 512);
const textBasedError = checkTextBasedDangerousSignature(trimmedHead);
if (textBasedError) {
return { isValid: false, errorKey: textBasedError.key, errorParams: textBasedError.params };
}
if (matchesSignature(trimmedHead, FILE_SIGNATURES.PDF)) {
return { isValid: false, errorKey: "unsupported_file_type", errorParams: { type: "PDF" } };
}
if (matchesSignature(trimmedHead, FILE_SIGNATURES.ZIP)) {
return { isValid: false, errorKey: "unsupported_file_type", errorParams: { type: "ZIP" } };
}
if (matchesSignature(trimmedHead, FILE_SIGNATURES.EXECUTABLE)) {
return { isValid: false, errorKey: "unsupported_file_type", errorParams: { type: "Executable" } };
}
const isValidImage =
matchesSignature(trimmedHead, FILE_SIGNATURES.PNG) ||
matchesSignature(trimmedHead, FILE_SIGNATURES.JPEG_FF_D8_FF) ||
matchesSignature(trimmedHead, FILE_SIGNATURES.GIF87a) ||
matchesSignature(trimmedHead, FILE_SIGNATURES.GIF89a) ||
matchesSignature(trimmedHead, FILE_SIGNATURES.BMP) ||
matchesSignature(trimmedHead, FILE_SIGNATURES.ICO) ||
(matchesSignature(trimmedHead, FILE_SIGNATURES.WEBP) &&
trimmedHead.length >= 12 &&
matchesSignature(trimmedHead.slice(8), FILE_SIGNATURES.WEBP_SIGNATURE)) ||
(() => {
const fullText = new TextDecoder("utf-8", { fatal: false }).decode(strippedData);
const svgTrimmedHead = createTrimmedTextHead(fullText);
return (
(matchesSignature(svgTrimmedHead, FILE_SIGNATURES.SVG) ||
matchesSignature(svgTrimmedHead, FILE_SIGNATURES.SVG_DIRECT)) &&
!containsDangerousSVGContent(fullText)
);
})();
if (!isValidImage) {
return { isValid: false, error: "invalid_image_file_format" };
}
return { isValid: true };
} catch (error) {
return { isValid: false, error: "failed_to_validate_image_file" };
}
};