Skip to content

Commit 15238c0

Browse files
committed
UIFC-262 Add 50 MB file upload size limit to prevent DoS attacks
- Implement client-side file size validation (reject > 50 MB before upload) - Refactor handleDrop to async/await for proper error propagation - Add helper functions: createFileSizeErrorMessage, handlePayloadTooLargeError - Sanitize backend error messages (reject HTML tags, >200 chars) to prevent XSS - Handle HTTP 413 with fallback for malformed responses - Fix validation error timing (show only after touch/submit, not immediately) - Extract HTTP_STATUS_PAYLOAD_TOO_LARGE constant - Add translations: fileTooLarge, backendRejected, network - Update CHANGELOG
1 parent 7b7cd33 commit 15238c0

5 files changed

Lines changed: 230 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 8.2.0 (IN PROGRESS)
44

5+
* Add file upload size validation and improved error handling ([UIFC-262](https://folio-org.atlassian.net/browse/UIFC-262))
56

67
## [8.1.0](https://github.com/folio-org/ui-finc-select/tree/v8.1.0) (2025-08-21)
78
* Address code duplications ([UIFC-342](https://folio-org.atlassian.net/browse/UIFC-342))

src/components/Filters/FilterFile/UploadFile/FileUploaderField.js

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,52 @@ import { useState, useEffect } from 'react';
33
import { useIntl } from 'react-intl';
44

55
import FileUploaderFieldView from './FileUploaderFieldView';
6+
import { isFileSizeValid, createFileSizeErrorDetails } from '../../../../util/fileUtils';
7+
8+
const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413;
9+
10+
/**
11+
* Helper function to create file size error message
12+
* @param {number} fileSize - Size of the file in bytes
13+
* @param {object} intl - Internationalization object
14+
* @returns {string} Formatted error message
15+
*/
16+
const createFileSizeErrorMessage = (fileSize, intl) => {
17+
const errorDetails = createFileSizeErrorDetails(fileSize);
18+
return intl.formatMessage(
19+
{ id: 'ui-finc-select.filter.file.uploadError.fileTooLarge' },
20+
{
21+
actualSize: errorDetails.actualSize,
22+
maxSize: errorDetails.maxSize,
23+
}
24+
);
25+
};
26+
27+
/**
28+
* Helper function to handle HTTP 413 Payload Too Large errors
29+
* Sanitizes backend messages to prevent information disclosure
30+
* @param {Response} response - HTTP response object
31+
* @param {File} file - The uploaded file
32+
* @param {object} intl - Internationalization object
33+
* @returns {Promise<string>} Error message
34+
*/
35+
const handlePayloadTooLargeError = async (response, file, intl) => {
36+
try {
37+
const backendMessage = await response.text();
38+
// Sanitize backend message - only show if it's a simple size message
39+
// Reject messages that are too long or contain HTML tags (potential XSS)
40+
if (backendMessage && backendMessage.length < 200 && !/<[^>]*>/.test(backendMessage)) {
41+
return intl.formatMessage(
42+
{ id: 'ui-finc-select.filter.file.uploadError.backendRejected' },
43+
{ message: backendMessage }
44+
);
45+
}
46+
} catch (error) {
47+
// Fall through to default message if reading response body fails
48+
}
49+
50+
return createFileSizeErrorMessage(file.size, intl);
51+
};
652

753
const FileUploaderField = ({
854
input: { onChange, value },
@@ -27,42 +73,50 @@ const FileUploaderField = ({
2773
}
2874
}, [value, file]);
2975

30-
const handleDrop = (acceptedFiles) => {
76+
const handleDrop = async (acceptedFiles) => {
3177
if (acceptedFiles.length !== 1) return;
3278

33-
let mounted = true;
79+
const file = acceptedFiles[0];
80+
81+
if (!isFileSizeValid(file.size)) {
82+
const errorMessage = createFileSizeErrorMessage(file.size, intl);
83+
setError(errorMessage);
84+
setIsDropZoneActive(false);
85+
setUploadInProgress(false);
86+
return;
87+
}
3488

3589
setError(undefined);
3690
setIsDropZoneActive(false);
3791
setUploadInProgress(true);
3892

39-
onUploadFile(acceptedFiles[0])
40-
.then(response => {
41-
if (response.ok) {
42-
// example: file = "34bdd9da-b765-448a-8519-11d460a4df5d"
43-
response.text().then(fileId => {
44-
// the value of the fieldId will connected with the Field in DocuemtsFieldArray with onChange(file);
45-
onChange(fileId);
46-
if (mounted) {
47-
setFile({ fileId });
48-
}
49-
});
50-
} else {
51-
throw new Error(intl.formatMessage({ id: 'ui-finc-select.filter.file.uploadError' }));
52-
}
53-
})
54-
.catch(err => {
55-
console.error(err); // eslint-disable-line no-console
56-
setError(err.message);
57-
setFile({});
58-
})
59-
.finally(() => setUploadInProgress(false));
60-
mounted = false;
93+
try {
94+
const response = await onUploadFile(file);
95+
96+
if (response.ok) {
97+
const fileId = await response.text();
98+
onChange(fileId);
99+
setFile({ fileId });
100+
} else if (response.status === HTTP_STATUS_PAYLOAD_TOO_LARGE) {
101+
const errorMessage = await handlePayloadTooLargeError(response, file, intl);
102+
throw new Error(errorMessage);
103+
} else {
104+
throw new Error(
105+
intl.formatMessage({ id: 'ui-finc-select.filter.file.uploadError.network' })
106+
);
107+
}
108+
} catch (err) {
109+
console.error(err); // eslint-disable-line no-console
110+
setError(err.message);
111+
setFile({});
112+
} finally {
113+
setUploadInProgress(false);
114+
}
61115
};
62116

63117
return (
64118
<FileUploaderFieldView
65-
error={meta.error || error}
119+
error={error || ((meta.touched || meta.submitFailed) && meta.error)}
66120
isDropZoneActive={isDropZoneActive}
67121
onDragEnter={() => setIsDropZoneActive(true)}
68122
onDragLeave={() => setIsDropZoneActive(false)}

src/util/fileUtils.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Maximum file upload size in bytes (50 MB)
2+
export const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 52,428,800 bytes
3+
export const MAX_FILE_SIZE_MB = 50;
4+
5+
/**
6+
* Validates if a file size is within the allowed limit
7+
* @param {number} sizeInBytes - File size in bytes
8+
* @returns {boolean} True if valid, false otherwise
9+
*/
10+
export const isFileSizeValid = (sizeInBytes) => {
11+
return sizeInBytes <= MAX_FILE_SIZE_BYTES;
12+
};
13+
14+
/**
15+
* Formats bytes to human-readable format
16+
* @param {number} bytes - Size in bytes
17+
* @param {number} decimals - Number of decimal places (default: 2)
18+
* @returns {string} Formatted size string (e.g., "45.5 MB")
19+
*/
20+
export const formatBytes = (bytes, decimals = 2) => {
21+
if (bytes === 0) return '0 Bytes';
22+
23+
const k = 1024;
24+
const dm = decimals < 0 ? 0 : decimals;
25+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
26+
27+
const i = Math.floor(Math.log(bytes) / Math.log(k));
28+
29+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
30+
};
31+
32+
/**
33+
* Creates a detailed error message for file size validation
34+
* @param {number} actualSizeBytes - Actual file size in bytes
35+
* @param {number} maxSizeBytes - Maximum allowed size in bytes
36+
* @returns {object} Error details with formatted message
37+
*/
38+
export const createFileSizeErrorDetails = (actualSizeBytes, maxSizeBytes = MAX_FILE_SIZE_BYTES) => {
39+
return {
40+
actualSize: formatBytes(actualSizeBytes),
41+
maxSize: formatBytes(maxSizeBytes),
42+
actualSizeBytes,
43+
maxSizeBytes,
44+
};
45+
};

src/util/fileUtils.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
MAX_FILE_SIZE_BYTES,
3+
MAX_FILE_SIZE_MB,
4+
isFileSizeValid,
5+
formatBytes,
6+
createFileSizeErrorDetails,
7+
} from './fileUtils';
8+
9+
describe('fileUtils', () => {
10+
describe('constants', () => {
11+
it('should have correct MAX_FILE_SIZE_BYTES', () => {
12+
expect(MAX_FILE_SIZE_BYTES).toBe(52428800); // 50 * 1024 * 1024
13+
});
14+
15+
it('should have correct MAX_FILE_SIZE_MB', () => {
16+
expect(MAX_FILE_SIZE_MB).toBe(50);
17+
});
18+
});
19+
20+
describe('isFileSizeValid', () => {
21+
it('should accept file exactly at limit', () => {
22+
expect(isFileSizeValid(MAX_FILE_SIZE_BYTES)).toBe(true);
23+
});
24+
25+
it('should accept file under limit', () => {
26+
expect(isFileSizeValid(MAX_FILE_SIZE_BYTES - 1)).toBe(true);
27+
expect(isFileSizeValid(1024)).toBe(true);
28+
expect(isFileSizeValid(1)).toBe(true);
29+
});
30+
31+
it('should reject file over limit', () => {
32+
expect(isFileSizeValid(MAX_FILE_SIZE_BYTES + 1)).toBe(false);
33+
expect(isFileSizeValid(100 * 1024 * 1024)).toBe(false);
34+
});
35+
36+
it('should handle zero size', () => {
37+
expect(isFileSizeValid(0)).toBe(true);
38+
});
39+
});
40+
41+
describe('formatBytes', () => {
42+
it('should format zero bytes', () => {
43+
expect(formatBytes(0)).toBe('0 Bytes');
44+
});
45+
46+
it('should format bytes correctly', () => {
47+
expect(formatBytes(100)).toBe('100 Bytes');
48+
expect(formatBytes(1024)).toBe('1 KB');
49+
expect(formatBytes(1048576)).toBe('1 MB');
50+
expect(formatBytes(MAX_FILE_SIZE_BYTES)).toBe('50 MB');
51+
});
52+
53+
it('should format with custom decimals', () => {
54+
expect(formatBytes(1536, 0)).toBe('2 KB');
55+
expect(formatBytes(1536, 3)).toBe('1.5 KB');
56+
expect(formatBytes(1536, 1)).toBe('1.5 KB');
57+
expect(formatBytes(1536000, 3)).toBe('1.465 MB');
58+
});
59+
60+
it('should handle large files', () => {
61+
expect(formatBytes(1073741824)).toBe('1 GB');
62+
expect(formatBytes(1073741824 * 2)).toBe('2 GB');
63+
});
64+
65+
it('should handle fractional MB', () => {
66+
expect(formatBytes(1572864)).toBe('1.5 MB');
67+
});
68+
});
69+
70+
describe('createFileSizeErrorDetails', () => {
71+
it('should create error details with formatted sizes', () => {
72+
const details = createFileSizeErrorDetails(100 * 1024 * 1024);
73+
expect(details.actualSize).toBe('100 MB');
74+
expect(details.maxSize).toBe('50 MB');
75+
expect(details.actualSizeBytes).toBe(104857600);
76+
expect(details.maxSizeBytes).toBe(MAX_FILE_SIZE_BYTES);
77+
});
78+
79+
it('should create error details for small files', () => {
80+
const details = createFileSizeErrorDetails(1024);
81+
expect(details.actualSize).toBe('1 KB');
82+
expect(details.maxSize).toBe('50 MB');
83+
expect(details.actualSizeBytes).toBe(1024);
84+
expect(details.maxSizeBytes).toBe(MAX_FILE_SIZE_BYTES);
85+
});
86+
87+
it('should handle custom max size', () => {
88+
const customMax = 10 * 1024 * 1024; // 10 MB
89+
const details = createFileSizeErrorDetails(15 * 1024 * 1024, customMax);
90+
expect(details.actualSize).toBe('15 MB');
91+
expect(details.maxSize).toBe('10 MB');
92+
expect(details.actualSizeBytes).toBe(15728640);
93+
expect(details.maxSizeBytes).toBe(customMax);
94+
});
95+
96+
it('should handle zero size', () => {
97+
const details = createFileSizeErrorDetails(0);
98+
expect(details.actualSize).toBe('0 Bytes');
99+
expect(details.maxSize).toBe('50 MB');
100+
});
101+
});
102+
});

translations/ui-finc-select/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@
100100
"filter.file.connected": "The filter file {filename} is connected.",
101101
"filter.file.placeholder.name": "Enter a name to identify the connected file",
102102
"filter.file.uploadError": "An error occurred during upload",
103+
"filter.file.uploadError.fileTooLarge": "File size ({actualSize}) exceeds the maximum allowed size of {maxSize}.",
104+
"filter.file.uploadError.backendRejected": "Server rejected the file: {message}",
105+
"filter.file.uploadError.network": "Network error occurred during upload. Please try again.",
103106

104107
"filter.generalAccordion": "General",
105108
"filter.fileAccordion": "File",

0 commit comments

Comments
 (0)