Skip to content

Commit 5acdb20

Browse files
Devanshusharma2005anikdhabalUdit-takkar
authored
feat: enhance image upload validation across the application (calcom#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>
1 parent 7bf5d9c commit 5acdb20

16 files changed

Lines changed: 1128 additions & 64 deletions

File tree

apps/api/v1/pages/api/users/[userId]/_patch.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { NextApiRequest } from "next";
33
import { HttpError } from "@calcom/lib/http-error";
44
import { uploadAvatar } from "@calcom/lib/server/avatar";
55
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
6+
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
7+
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
68
import prisma from "@calcom/prisma";
79
import type { Prisma } from "@calcom/prisma/client";
810

@@ -124,10 +126,25 @@ export async function patchHandler(req: NextApiRequest) {
124126
}
125127

126128
if (avatar) {
127-
body.avatarUrl = await uploadAvatar({
128-
userId: query.userId,
129-
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
130-
});
129+
const validation = validateBase64Image(avatar);
130+
if (!validation.isValid) {
131+
throw new HttpError({
132+
statusCode: 400,
133+
message: `Invalid avatar image: ${validation.error}`,
134+
});
135+
}
136+
137+
try {
138+
body.avatarUrl = await uploadAvatar({
139+
userId: query.userId,
140+
avatar: await resizeBase64Image(avatar),
141+
});
142+
} catch (error) {
143+
throw new HttpError({
144+
statusCode: 400,
145+
message: error instanceof Error ? error.message : "Failed to upload avatar",
146+
});
147+
}
131148
}
132149

133150
const data = await prisma.user.update({

apps/web/app/api/avatar/[uuid]/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from "zod";
66

77
import { AVATAR_FALLBACK, WEBAPP_URL } from "@calcom/lib/constants";
88
import { convertSvgToPng } from "@calcom/lib/server/imageUtils";
9+
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
910
import prisma from "@calcom/prisma";
1011

1112
const querySchema = z.object({
@@ -46,6 +47,12 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
4647
},
4748
});
4849

50+
const validation = validateBase64Image(data);
51+
if (!validation.isValid) {
52+
const url = new URL(AVATAR_FALLBACK, WEBAPP_URL).toString();
53+
return NextResponse.redirect(url, 302);
54+
}
55+
4956
// Convert SVG to PNG if needed and update the database
5057
if (data.startsWith("data:image/svg+xml;base64,")) {
5158
const pngData = await convertSvgToPng(data);
@@ -72,6 +79,11 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
7279
"Content-Type": "image/png",
7380
"Content-Length": imageResp.length.toString(),
7481
"Cache-Control": "max-age=86400",
82+
// Security headers to prevent XSS
83+
"X-Content-Type-Options": "nosniff",
84+
"Content-Disposition": "inline",
85+
"X-Frame-Options": "DENY",
86+
"Content-Security-Policy": "default-src 'none'; img-src 'self'",
7587
},
7688
status: 200,
7789
});

apps/web/public/static/locales/en/common.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3581,6 +3581,14 @@
35813581
"webhook_metadata": "Metadata",
35823582
"stats": "Stats",
35833583
"booking_status": "Booking status",
3584+
"unsupported_file_type": "{{type}} files cannot be uploaded as images",
3585+
"only_image_files_allowed": "Only image files are allowed",
3586+
"failed_to_validate_image_file": "Failed to validate image file",
3587+
"invalid_image_file_format": "Invalid image file format",
3588+
"svg_contains_dangerous_content": "SVG contains potentially dangerous content",
3589+
"unrecognized_image_format": "Unrecognized image format or invalid file",
3590+
"invalid_base64_format": "Invalid base64 format",
3591+
"empty_image_data": "Empty image data",
35843592
"visit": "Visit",
35853593
"location_custom_label_input_label": "Custom label on booking page",
35863594
"meeting_link": "Meeting link",

packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OAuth2Client } from "googleapis-common";
33

44
import logger from "@calcom/lib/logger";
55
import { uploadAvatar } from "@calcom/lib/server/avatar";
6+
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
67
import { UserRepository } from "@calcom/lib/server/repository/user";
78
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
89
import prisma from "@calcom/prisma";
@@ -16,12 +17,17 @@ export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userI
1617
return;
1718
}
1819

19-
// Handle base64 data
2020
if (
2121
avatarUrl.startsWith("data:image/png;base64,") ||
2222
avatarUrl.startsWith("data:image/jpeg;base64,") ||
2323
avatarUrl.startsWith("data:image/jpg;base64,")
2424
) {
25+
const validation = validateBase64Image(avatarUrl);
26+
if (!validation.isValid) {
27+
logger.error(`Invalid avatar image from Google OAuth: ${validation.error}`);
28+
return;
29+
}
30+
2531
const resizedAvatarUrl = await uploadAvatar({
2632
avatar: await resizeBase64Image(avatarUrl),
2733
userId,

packages/lib/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export const MAX_EVENT_DURATION_MINUTES = 1440;
6868
/** Minimum duration allowed for an event in minutes */
6969
export const MIN_EVENT_DURATION_MINUTES = 1;
7070

71+
/** Maximum file size allowed for banner uploads in bytes (5MB) */
72+
export const MAX_BANNER_SIZE = 5 * 1024 * 1024;
73+
7174
export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED;
7275

7376
export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS =
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Shared image validation constants and utilities
3+
* Used by both server-side and client-side validation modules
4+
*/
5+
6+
/**
7+
* Magic numbers (file signatures) for different file types
8+
*/
9+
export const FILE_SIGNATURES = {
10+
PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
11+
JPEG_FF_D8_FF: [0xff, 0xd8, 0xff],
12+
GIF87a: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
13+
GIF89a: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
14+
WEBP: [0x52, 0x49, 0x46, 0x46],
15+
WEBP_SIGNATURE: [0x57, 0x45, 0x42, 0x50],
16+
BMP: [0x42, 0x4d],
17+
ICO: [0x00, 0x00, 0x01, 0x00],
18+
SVG: [0x3c, 0x3f, 0x78, 0x6d, 0x6c],
19+
SVG_DIRECT: [0x3c, 0x73, 0x76, 0x67],
20+
21+
PDF: [0x25, 0x50, 0x44, 0x46],
22+
HTML: [0x3c, 0x21, 0x44, 0x4f, 0x43, 0x54, 0x59, 0x50, 0x45],
23+
HTML_TAG: [0x3c, 0x68, 0x74, 0x6d, 0x6c],
24+
SCRIPT_TAG: [0x3c, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74],
25+
ZIP: [0x50, 0x4b, 0x03, 0x04],
26+
EXECUTABLE: [0x4d, 0x5a],
27+
} as const;
28+
29+
/**
30+
* Check if bytes match a signature
31+
*/
32+
export function matchesSignature(data: Uint8Array, signature: readonly number[]): boolean {
33+
if (data.length < signature.length) return false;
34+
return signature.every((byte, index) => data[index] === byte);
35+
}
36+
37+
/**
38+
* SVG content validation patterns
39+
*/
40+
export const DANGEROUS_SVG_PATTERNS = [
41+
"<script",
42+
"javascript:",
43+
"onload=",
44+
"onclick=",
45+
"onmouseover=",
46+
"onmouseout=",
47+
"onfocus=",
48+
"onblur=",
49+
] as const;
50+
51+
/**
52+
* Validate SVG content for dangerous patterns
53+
*/
54+
export function containsDangerousSVGContent(content: string): boolean {
55+
return DANGEROUS_SVG_PATTERNS.some((pattern) => content.includes(pattern));
56+
}
57+
58+
/**
59+
* Base64 regex pattern that matches valid base64 strings
60+
* - Matches complete base64 groups of 4 characters
61+
* - Handles proper padding with = characters
62+
* - Supports both padded and unpadded forms correctly
63+
*/
64+
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=)?$/;
65+
66+
/**
67+
* Validate base64 string format using strict regex
68+
*/
69+
export function isValidBase64(str: string): boolean {
70+
return BASE64_REGEX.test(str);
71+
}

packages/lib/server/avatar.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { v4 as uuidv4 } from "uuid";
33
import { prisma } from "@calcom/prisma";
44

55
import { convertSvgToPng } from "./imageUtils";
6+
import { validateBase64Image } from "./imageValidation";
67

78
export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
9+
const validation = validateBase64Image(data);
10+
if (!validation.isValid) {
11+
throw new Error(`Invalid image data: ${validation.error}`);
12+
}
13+
814
const objectKey = uuidv4();
915
const processedData = await convertSvgToPng(data);
1016

@@ -40,6 +46,11 @@ export const uploadLogo = async ({
4046
logo: string;
4147
isBanner?: boolean;
4248
}): Promise<string> => {
49+
const validation = validateBase64Image(data);
50+
if (!validation.isValid) {
51+
throw new Error(`Invalid image data: ${validation.error}`);
52+
}
53+
4354
const objectKey = uuidv4();
4455
const processedData = await convertSvgToPng(data);
4556

0 commit comments

Comments
 (0)