From 5acdb208f53a88b4f4c9aa9441436054eb95a421 Mon Sep 17 00:00:00 2001 From: Devanshu Sharma Date: Thu, 4 Sep 2025 18:22:36 +0530 Subject: [PATCH] feat: enhance image upload validation across the application (#22766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../api/v1/pages/api/users/[userId]/_patch.ts | 25 +- apps/web/app/api/avatar/[uuid]/route.ts | 12 + apps/web/public/static/locales/en/common.json | 8 + .../_utils/oauth/updateProfilePhotoGoogle.ts | 8 +- packages/lib/constants.ts | 3 + packages/lib/imageValidationConstants.ts | 71 ++++ packages/lib/server/avatar.ts | 11 + packages/lib/server/imageValidation.test.ts | 320 ++++++++++++++++++ packages/lib/server/imageValidation.ts | 132 ++++++++ .../viewer/me/updateProfile.handler.ts | 32 +- .../viewer/organizations/update.handler.ts | 88 +++-- .../organizations/updateUser.handler.ts | 48 ++- .../image-uploader/BannerUploader.tsx | 21 +- .../image-uploader/ImageUploader.tsx | 23 +- .../image-uploader/imageValidation.test.ts | 259 ++++++++++++++ .../image-uploader/imageValidation.ts | 131 +++++++ 16 files changed, 1128 insertions(+), 64 deletions(-) create mode 100644 packages/lib/imageValidationConstants.ts create mode 100644 packages/lib/server/imageValidation.test.ts create mode 100644 packages/lib/server/imageValidation.ts create mode 100644 packages/ui/components/image-uploader/imageValidation.test.ts create mode 100644 packages/ui/components/image-uploader/imageValidation.ts diff --git a/apps/api/v1/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts index 3f1cf43a04..f073c05b35 100644 --- a/apps/api/v1/pages/api/users/[userId]/_patch.ts +++ b/apps/api/v1/pages/api/users/[userId]/_patch.ts @@ -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({ diff --git a/apps/web/app/api/avatar/[uuid]/route.ts b/apps/web/app/api/avatar/[uuid]/route.ts index 348bb567ca..eb4308079a 100644 --- a/apps/web/app/api/avatar/[uuid]/route.ts +++ b/apps/web/app/api/avatar/[uuid]/route.ts @@ -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 } }, }); + 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 } "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, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 207f1f530e..0dcec6b8f7 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts index 5526343d87..5a9d3d4eae 100644 --- a/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts +++ b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts @@ -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, diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index a414ace851..42a66ac4b1 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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 = diff --git a/packages/lib/imageValidationConstants.ts b/packages/lib/imageValidationConstants.ts new file mode 100644 index 0000000000..2a2c646bac --- /dev/null +++ b/packages/lib/imageValidationConstants.ts @@ -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 = [ + " { + 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 => { + const validation = validateBase64Image(data); + if (!validation.isValid) { + throw new Error(`Invalid image data: ${validation.error}`); + } + const objectKey = uuidv4(); const processedData = await convertSvgToPng(data); diff --git a/packages/lib/server/imageValidation.test.ts b/packages/lib/server/imageValidation.test.ts new file mode 100644 index 0000000000..3a7751dbb3 --- /dev/null +++ b/packages/lib/server/imageValidation.test.ts @@ -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 = + ''; + 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 tag", () => { + const svgContent = ''; + 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 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 = ''; + 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 = + 'link'; + 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 = ''; + 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"); + }); + }); +}); diff --git a/packages/lib/server/imageValidation.ts b/packages/lib/server/imageValidation.ts new file mode 100644 index 0000000000..2817cca9e5 --- /dev/null +++ b/packages/lib/server/imageValidation.ts @@ -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; + 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" }; + } +} diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts index 9936331b20..f97ccf5e75 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts @@ -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) { diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 5957c63b0c..a672c3a9f8 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -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, }); diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts index 901abbe1eb..334e7a50da 100644 --- a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts @@ -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 diff --git a/packages/ui/components/image-uploader/BannerUploader.tsx b/packages/ui/components/image-uploader/BannerUploader.tsx index 458ae8e94d..dbb6b37a1a 100644 --- a/packages/ui/components/image-uploader/BannerUploader.tsx +++ b/packages/ui/components/image-uploader/BannerUploader.tsx @@ -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")} diff --git a/packages/ui/components/image-uploader/ImageUploader.tsx b/packages/ui/components/image-uploader/ImageUploader.tsx index 12ab7e0c67..4567854cf3 100644 --- a/packages/ui/components/image-uploader/ImageUploader.tsx +++ b/packages/ui/components/image-uploader/ImageUploader.tsx @@ -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) => { + const onInputFile = async (e: FileEvent) => { 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")} diff --git a/packages/ui/components/image-uploader/imageValidation.test.ts b/packages/ui/components/image-uploader/imageValidation.test.ts new file mode 100644 index 0000000000..1713e19362 --- /dev/null +++ b/packages/ui/components/image-uploader/imageValidation.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/ui/components/image-uploader/imageValidation.ts b/packages/ui/components/image-uploader/imageValidation.ts new file mode 100644 index 0000000000..25d1750c97 --- /dev/null +++ b/packages/ui/components/image-uploader/imageValidation.ts @@ -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 } | null => { + const text = new TextDecoder("utf-8", { fatal: false }).decode(data).toLowerCase(); + + if (text.includes(" { + 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; +}> => { + 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" }; + } +};