* 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>
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|