Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
adc8667
feat: enhance image upload validation across the application
Devanshusharma2005 Jul 28, 2025
d784c74
Patch improvements.
Devanshusharma2005 Jul 28, 2025
279ea9b
refactor: streamline avatar upload error handling in updateProfile ha…
Devanshusharma2005 Jul 28, 2025
8b8f1df
refactor: extract image validation logic into a separate module for r…
Devanshusharma2005 Jul 28, 2025
222acae
fix: reset file input value on validation failure in image uploaders
Devanshusharma2005 Jul 28, 2025
45774f8
fix: update localization key for image file upload error message
Devanshusharma2005 Jul 28, 2025
fe1451a
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 28, 2025
797e4c4
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 28, 2025
f6a2fc6
fix: update HTML content validation in image uploader to check for ad…
Devanshusharma2005 Jul 28, 2025
8414871
Merge branch 'fix/image-upload-validation-fix' of https://github.com/…
Devanshusharma2005 Jul 28, 2025
5193901
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 28, 2025
b6bdcbb
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 29, 2025
dcb9804
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 29, 2025
93b32ab
Code Quality improvements
Devanshusharma2005 Jul 30, 2025
a64a0a1
add : unit tests.
Devanshusharma2005 Jul 30, 2025
d495006
fix : fixing some more issues.
Devanshusharma2005 Jul 30, 2025
6db09ec
fix : some import fixes
Devanshusharma2005 Jul 30, 2025
c60d660
fix : fixing type-check issues
Devanshusharma2005 Jul 30, 2025
42d9de1
fix : some more import fixes.
Devanshusharma2005 Jul 30, 2025
720401f
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 30, 2025
63ffc64
fix : removing Duplicate max-file-size constant
Devanshusharma2005 Jul 30, 2025
cb7be2b
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Jul 31, 2025
bfa4ffe
refactor: enhance base64 validation with regex pattern
Devanshusharma2005 Jul 31, 2025
24a3369
Merge branch 'fix/image-upload-validation-fix' of https://github.com/…
Devanshusharma2005 Jul 31, 2025
fd718b1
refactor: centralize MAX_IMAGE_FILE_SIZE and fix SVG checks.
Devanshusharma2005 Aug 1, 2025
0590d7e
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 1, 2025
505c6a3
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 1, 2025
6938c09
fix : some toast fixes.
Devanshusharma2005 Aug 1, 2025
ad90fed
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 1, 2025
2d236b1
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 1, 2025
7709b7f
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 4, 2025
4db7122
fixing the size of the banner.
Devanshusharma2005 Aug 4, 2025
4bee56e
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 4, 2025
225fe62
fix: update accepted image formats in BannerUploader and ImageUploade…
Devanshusharma2005 Aug 4, 2025
a18c66b
Merge branch 'fix/image-upload-validation-fix' of https://github.com/…
Devanshusharma2005 Aug 4, 2025
71927f6
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 4, 2025
bf999a8
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 5, 2025
ec152f9
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 6, 2025
cb6ff7e
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 7, 2025
9c4f036
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Aug 18, 2025
855d40c
Merge branch 'main' into fix/image-upload-validation-fix
anikdhabal Aug 18, 2025
9f480d0
Merge branch 'main' into fix/image-upload-validation-fix
Udit-takkar Aug 20, 2025
447b103
Merge branch 'main' into fix/image-upload-validation-fix
anikdhabal Aug 26, 2025
1c7e384
Merge branch 'main' into fix/image-upload-validation-fix
anikdhabal Sep 2, 2025
fddd891
Merge branch 'main' into fix/image-upload-validation-fix
anikdhabal Sep 2, 2025
78cfeed
fix
anikdhabal Sep 2, 2025
04cf5e3
coderabbit suggestions addressed.
Devanshusharma2005 Sep 2, 2025
7fdcd3f
addressed coderrabit comment
anikdhabal Sep 2, 2025
5f3bb95
refactor: implement generic i18n error messages with interpolation
Devanshusharma2005 Sep 2, 2025
61f1cc3
feat: add MAX_BANNER_SIZE constant and update file size validation
Devanshusharma2005 Sep 2, 2025
57c51ee
adressed volnei's suggestions.
Devanshusharma2005 Sep 2, 2025
b223904
test: update image validation tests for new errorKey/errorParams pattern
Devanshusharma2005 Sep 2, 2025
2f266d9
test: update server-side image validation tests for new error format
Devanshusharma2005 Sep 2, 2025
a6a25e4
Merge branch 'main' into fix/image-upload-validation-fix
Devanshusharma2005 Sep 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/api/v1/pages/api/users/[userId]/_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions apps/web/app/api/avatar/[uuid]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
Expand Down
8 changes: 8 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3576,6 +3576,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",
Expand Down
8 changes: 7 additions & 1 deletion packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
71 changes: 71 additions & 0 deletions packages/lib/imageValidationConstants.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions packages/lib/server/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading