Files
picpeak/backend/src/utils/fileSecurityUtils.js
T
paul 4e977f7624
Test Gitea Actions / test (push) Successful in 14s
continuous-integration/drone/push Build is passing
fix(security): allow serving static files from uploads directory
- Remove overly restrictive absolute path check in isPathSafe
- Strip leading slash from request path before validation
- Fixes broken favicon and watermark image previews in branding page
- Path traversal protection remains intact with ../ pattern checks

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:43:29 +02:00

233 lines
6.7 KiB
JavaScript

const path = require('path');
const fs = require('fs').promises;
/**
* Secure file security utilities to prevent path traversal and validate file types
*/
/**
* Safely join paths and prevent directory traversal attacks
* @param {string} basePath - The base directory path
* @param {string} userPath - The user-provided path to join
* @returns {string} - Safe joined path
* @throws {Error} - If path traversal is detected
*/
function safePathJoin(basePath, userPath) {
// Normalize the base path
const normalizedBase = path.resolve(basePath);
// Join and resolve the full path
const joinedPath = path.join(normalizedBase, userPath);
const resolvedPath = path.resolve(joinedPath);
// Ensure the resolved path starts with the base path
if (!resolvedPath.startsWith(normalizedBase + path.sep) && resolvedPath !== normalizedBase) {
throw new Error('Path traversal attempt detected');
}
return resolvedPath;
}
/**
* Validate file path to prevent directory traversal
* @param {string} filePath - The file path to validate
* @returns {boolean} - True if path is safe
*/
function isPathSafe(filePath) {
// Check for common path traversal patterns
const dangerousPatterns = [
/\.\.[\/\\]/, // ../ or ..\
/^[A-Za-z]:/, // Windows drive letters
/[\x00-\x1f]/ // Control characters
];
return !dangerousPatterns.some(pattern => pattern.test(filePath));
}
/**
* Enhanced MIME type validation
*/
const ALLOWED_IMAGE_TYPES = {
'image/jpeg': {
extensions: ['.jpg', '.jpeg'],
magicNumbers: [
{ offset: 0, bytes: [0xFF, 0xD8, 0xFF] } // JPEG
]
},
'image/png': {
extensions: ['.png'],
magicNumbers: [
{ offset: 0, bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] } // PNG
]
},
'image/webp': {
extensions: ['.webp'],
magicNumbers: [
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } // WEBP
]
},
'image/gif': {
extensions: ['.gif'],
magicNumbers: [
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a
]
},
'image/svg+xml': {
extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null
}
};
/**
* Validate file type by MIME type and extension
* @param {string} filename - The filename
* @param {string} mimetype - The MIME type
* @param {string[]} allowedTypes - Array of allowed MIME types
* @returns {boolean} - True if file type is valid
*/
function validateFileType(filename, mimetype, allowedTypes) {
// Check if MIME type is allowed
if (!allowedTypes.includes(mimetype)) {
return false;
}
// Get file extension
const ext = path.extname(filename).toLowerCase();
// Check if extension matches the MIME type
const typeConfig = ALLOWED_IMAGE_TYPES[mimetype];
if (!typeConfig || !typeConfig.extensions.includes(ext)) {
return false;
}
return true;
}
/**
* Validate file content by checking magic numbers (file signatures)
* @param {string} filePath - Path to the file
* @param {string} expectedMimeType - Expected MIME type
* @returns {Promise<boolean>} - True if file content matches expected type
*/
async function validateFileContent(filePath, expectedMimeType) {
try {
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType];
if (!typeConfig) {
return false;
}
// Skip validation for file types without magic numbers (like SVG)
if (!typeConfig.magicNumbers) {
return true;
}
// Read the first 20 bytes of the file (enough for most magic numbers)
const buffer = Buffer.alloc(20);
const fileHandle = await fs.open(filePath, 'r');
await fileHandle.read(buffer, 0, 20, 0);
await fileHandle.close();
// Check magic numbers
return typeConfig.magicNumbers.every(magic => {
for (let i = 0; i < magic.bytes.length; i++) {
if (buffer[magic.offset + i] !== magic.bytes[i]) {
return false;
}
}
return true;
});
} catch (error) {
console.error('Error validating file content:', error);
return false;
}
}
/**
* Get safe filename for storage
* @param {string} originalFilename - Original filename
* @returns {string} - Safe filename
*/
function getSafeFilename(originalFilename) {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const ext = path.extname(originalFilename).toLowerCase();
// Validate extension
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico'];
if (!validExtensions.includes(ext)) {
throw new Error('Invalid file extension');
}
return `upload_${timestamp}_${randomString}${ext}`;
}
/**
* Create a file upload validator middleware
* @param {Object} options - Validation options
* @returns {Function} - Express middleware function
*/
function createFileUploadValidator(options = {}) {
const {
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
maxFileSize = 50 * 1024 * 1024, // 50MB default
validateContent = true
} = options;
return async (req, res, next) => {
try {
if (!req.files || req.files.length === 0) {
return next();
}
for (const file of req.files) {
// Validate file type
if (!validateFileType(file.originalname, file.mimetype, allowedTypes)) {
return res.status(400).json({
error: `Invalid file type: ${file.originalname}. Allowed types: ${allowedTypes.join(', ')}`
});
}
// Validate file size
if (file.size > maxFileSize) {
return res.status(400).json({
error: `File too large: ${file.originalname}. Maximum size: ${maxFileSize / 1024 / 1024}MB`
});
}
// Validate file content if enabled
if (validateContent && file.path) {
const isValidContent = await validateFileContent(file.path, file.mimetype);
if (!isValidContent) {
// Remove the file if content doesn't match
try {
await fs.unlink(file.path);
} catch (err) {
console.error('Error removing invalid file:', err);
}
return res.status(400).json({
error: `File content does not match declared type: ${file.originalname}`
});
}
}
}
next();
} catch (error) {
console.error('File validation error:', error);
res.status(500).json({ error: 'File validation failed' });
}
};
}
module.exports = {
safePathJoin,
isPathSafe,
validateFileType,
validateFileContent,
getSafeFilename,
createFileUploadValidator,
ALLOWED_IMAGE_TYPES
};