Task 10: Harden file upload validation

Enhanced validateFileUpload() function in functions.php with comprehensive security:
- Hardcoded MIME type whitelist per file type (profile_picture, proof_of_payment, document)
- Strict file size limits per type (5MB images, 10MB documents)
- Extension validation against whitelist
- Double extension prevention (e.g., shell.php.jpg)
- MIME type verification using finfo
- Image validation with getimagesize()
- is_uploaded_file() verification
- Random filename generation to prevent path traversal

Updated file upload handlers:
- upload_profile_picture.php - Profile picture uploads (JPEG, PNG, GIF, WEBP, 5MB max)
- submit_pop.php - Proof of payment uploads (PDF only, 10MB max) + CSRF validation + audit logging
- add_campsite.php - Campsite thumbnail uploads + input validation + CSRF validation + audit logging

Security improvements:
- All uploads use random filenames to prevent directory traversal
- All uploads use secure file permissions (0644)
- File validation occurs before move_uploaded_file()
- Comprehensive error logging for failed uploads
- Audit logging for successful file operations
This commit is contained in:
twotalesanimation
2025-12-03 13:30:45 +02:00
parent 7b1c20410c
commit b120415d53
4 changed files with 279 additions and 140 deletions

View File

@@ -2,7 +2,7 @@
include_once('functions.php');
require_once("env.php");
session_start();
$user_id = $_SESSION['user_id']; // assuming you're storing it like this
$user_id = $_SESSION['user_id'] ?? null;
// CSRF Token Validation
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
@@ -14,26 +14,48 @@ if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
$conn = openDatabaseConnection();
// Get text inputs
$name = $_POST['name'];
$desc = $_POST['description'];
$lat = $_POST['latitude'];
$lng = $_POST['longitude'];
$website = $_POST['website'];
$telephone = $_POST['telephone'];
$name = validateName($_POST['name'] ?? '') ?: '';
$desc = isset($_POST['description']) ? htmlspecialchars($_POST['description'], ENT_QUOTES, 'UTF-8') : '';
$lat = isset($_POST['latitude']) ? floatval($_POST['latitude']) : 0.0;
$lng = isset($_POST['longitude']) ? floatval($_POST['longitude']) : 0.0;
$website = isset($_POST['website']) ? filter_var($_POST['website'], FILTER_VALIDATE_URL) : '';
$telephone = validatePhoneNumber($_POST['telephone'] ?? '') ?: '';
if (empty($name)) {
http_response_code(400);
die('Campsite name is required.');
}
// Handle file upload
$thumbnailPath = null;
if (isset($_FILES['thumbnail']) && $_FILES['thumbnail']['error'] == 0) {
$uploadDir = "assets/uploads/campsites/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
if (isset($_FILES['thumbnail']) && $_FILES['thumbnail']['error'] !== UPLOAD_ERR_NO_FILE) {
// Validate file using hardened validation function
$validationResult = validateFileUpload($_FILES['thumbnail'], 'profile_picture');
if ($validationResult === false) {
http_response_code(400);
die('Invalid thumbnail image. Only JPG, JPEG, PNG, GIF, and WEBP images under 5MB are allowed.');
}
$filename = time() . "_" . basename($_FILES["thumbnail"]["name"]);
$targetFile = $uploadDir . $filename;
$uploadDir = "assets/uploads/campsites/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (!is_writable($uploadDir)) {
http_response_code(500);
die('Upload directory is not writable.');
}
$randomFilename = $validationResult['filename'];
$targetFile = $uploadDir . $randomFilename;
if (move_uploaded_file($_FILES["thumbnail"]["tmp_name"], $targetFile)) {
chmod($targetFile, 0644);
$thumbnailPath = $targetFile;
} else {
http_response_code(500);
die('Failed to move uploaded file.');
}
}
@@ -48,14 +70,25 @@ if ($id > 0) {
$stmt = $conn->prepare("UPDATE campsites SET name=?, description=?, latitude=?, longitude=?, website=?, telephone=? WHERE id=?");
$stmt->bind_param("ssddssi", $name, $desc, $lat, $lng, $website, $telephone, $id);
}
// Log the action
auditLog($user_id, 'CAMPSITE_UPDATE', 'campsites', $id, ['name' => $name]);
} else {
// INSERT
$stmt = $conn->prepare("INSERT INTO campsites (name, description, latitude, longitude, website, telephone, thumbnail, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param("ssddsssi", $name, $desc, $lat, $lng, $website, $telephone, $thumbnailPath, $user_id);
// Log the action
auditLog($user_id, 'CAMPSITE_CREATE', 'campsites', 0, ['name' => $name]);
}
$stmt->execute();
if (!$stmt->execute()) {
http_response_code(500);
die('Database error: ' . $stmt->error);
}
$stmt->close();
header("Location: campsites.php");
?>

View File

@@ -2280,40 +2280,134 @@ function sanitizeTextInput($text, $maxLength = 1000) {
* @param int $maxSize Maximum file size in bytes
* @return string|false Sanitized filename or false if invalid
*/
function validateFileUpload($file, $allowedTypes = [], $maxSize = 5242880) { // 5MB default
// Check for upload errors
if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
/**
* HARDENED FILE UPLOAD VALIDATION
*
* Validates file uploads with strict security checks:
* - Verifies upload completion
* - Enforces strict MIME type validation using finfo
* - Enforces strict file size limits per type
* - Validates extensions against whitelist
* - Prevents double extensions (e.g., .php.jpg)
* - Generates random filenames to prevent path traversal
* - Validates actual file content matches extension
*
* @param array $file The $_FILES['fieldname'] array
* @param string $fileType The type of file being uploaded (profile_picture, proof_of_payment, document)
* @return array|false Returns ['filename' => randomName, 'extension' => ext] on success, false on failure
*/
function validateFileUpload($file, $fileType = 'document') {
// ===== CONFIGURATION: HARDCODED TYPE WHITELIST =====
$fileTypeConfig = [
'profile_picture' => [
'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'mimeTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'maxSize' => 5242880, // 5MB
'description' => 'Profile Picture'
],
'proof_of_payment' => [
'extensions' => ['pdf'],
'mimeTypes' => ['application/pdf'],
'maxSize' => 10485760, // 10MB
'description' => 'Proof of Payment'
],
'document' => [
'extensions' => ['pdf', 'doc', 'docx'],
'mimeTypes' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'maxSize' => 10485760, // 10MB
'description' => 'Document'
]
];
// Validate fileType exists in config
if (!isset($fileTypeConfig[$fileType])) {
error_log("Invalid file type requested: $fileType");
return false;
}
// Check file size
if (!isset($file['size']) || $file['size'] > $maxSize) {
$config = $fileTypeConfig[$fileType];
// ===== CHECK 1: Upload Error =====
if (!isset($file['error']) || !isset($file['tmp_name']) || !isset($file['size']) || !isset($file['name'])) {
error_log("File upload validation: Missing required file array keys");
return false;
}
// Check MIME type
if ($file['error'] !== UPLOAD_ERR_OK) {
error_log("File upload error code: {$file['error']} for type: $fileType");
return false;
}
// ===== CHECK 2: File Size Limit =====
if ($file['size'] > $config['maxSize']) {
error_log("File size {$file['size']} exceeds limit {$config['maxSize']} for type: $fileType");
return false;
}
if ($file['size'] <= 0) {
error_log("File size is zero or negative for type: $fileType");
return false;
}
// ===== CHECK 3: Extension Validation (Case-Insensitive) =====
$pathinfo = pathinfo($file['name']);
$extension = strtolower($pathinfo['extension'] ?? '');
if (empty($extension) || !in_array($extension, $config['extensions'], true)) {
error_log("Invalid extension '$extension' for type: $fileType. Allowed: " . implode(', ', $config['extensions']));
return false;
}
// ===== CHECK 4: Prevent Double Extensions (e.g., shell.php.jpg) =====
if (isset($pathinfo['filename'])) {
// Check if filename contains suspicious extensions
$suspiciousPatterns = ['/\.php/', '/\.phtml/', '/\.phar/', '/\.sh/', '/\.bat/', '/\.exe/', '/\.com/'];
foreach ($suspiciousPatterns as $pattern) {
if (preg_match($pattern, $pathinfo['filename'], $matches)) {
error_log("Suspicious pattern detected in filename: {$pathinfo['filename']} for type: $fileType");
return false;
}
}
}
// ===== CHECK 5: MIME Type Validation =====
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
error_log("Failed to open fileinfo resource");
return false;
}
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes, true)) {
if (!in_array($mimeType, $config['mimeTypes'], true)) {
error_log("Invalid MIME type '$mimeType' for type: $fileType. Expected: " . implode(', ', $config['mimeTypes']));
return false;
}
// Generate random filename with original extension
$pathinfo = pathinfo($file['name']);
$extension = strtolower($pathinfo['extension']);
// ===== CHECK 6: Additional Image Validation (for images) =====
if (in_array($fileType, ['profile_picture'])) {
$imageInfo = @getimagesize($file['tmp_name']);
if ($imageInfo === false) {
error_log("File is not a valid image for type: $fileType");
return false;
}
}
// Whitelist allowed extensions
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'gif', 'webp'];
if (!in_array($extension, $allowedExtensions)) {
// ===== CHECK 7: Verify File is Actually Uploaded (Not Executed) =====
if (!is_uploaded_file($file['tmp_name'])) {
error_log("File is not a valid uploaded file for type: $fileType");
return false;
}
// Generate random filename
// ===== GENERATE RANDOM FILENAME =====
$randomName = bin2hex(random_bytes(16)) . '.' . $extension;
return $randomName;
return [
'filename' => $randomName,
'extension' => $extension,
'mimeType' => $mimeType
];
}
// ==================== RATE LIMITING & ACCOUNT LOCKOUT FUNCTIONS ====================

View File

@@ -1,7 +1,6 @@
<?php include_once('header02.php');
require_once("functions.php");
checkUserSession();
umask(002); // At the top of the PHP script, before move_uploaded_file()
$user_id = $_SESSION['user_id'] ?? null;
@@ -11,48 +10,44 @@ if (!$user_id) {
// Handle POST submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$eft_id = $_POST['eft_id'] ?? null;
$file_name = str_replace(' ', '_', $eft_id);
// CSRF Token Validation
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
http_response_code(403);
die('Security token validation failed. Please try again.');
}
$eft_id = $_POST['eft_id'] ?? null;
if (!$eft_id || !isset($_FILES['pop_file'])) {
echo "<div class='alert alert-danger'>Invalid submission: missing eft_id or file.</div>";
echo "<pre>";
echo "POST data: " . print_r($_POST, true);
echo "FILES data: " . print_r($_FILES, true);
echo "</pre>";
} else {
$file = $_FILES['pop_file'];
exit;
}
// Validate file using hardened validation function
$validationResult = validateFileUpload($_FILES['pop_file'], 'proof_of_payment');
if ($validationResult === false) {
echo "<div class='alert alert-danger'>Invalid file. Only PDF files under 10MB are allowed.</div>";
exit;
}
$target_dir = "uploads/pop/";
$target_file = $target_dir . $file_name . ".pdf";
// Check for upload errors first
if ($file['error'] !== UPLOAD_ERR_OK) {
echo "<div class='alert alert-danger'>Upload error code: " . $file['error'] . "</div>";
// You can decode error code if needed:
// https://www.php.net/manual/en/features.file-upload.errors.php
exit;
}
// Check for PDF extension
$file_type = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($file_type !== "pdf") {
echo "<div class='alert alert-danger'>Only PDF files allowed. You tried uploading: .$file_type</div>";
exit;
}
$randomFilename = $validationResult['filename'];
$target_file = $target_dir . $randomFilename;
// Make sure target directory exists and writable
if (!is_dir($target_dir)) {
echo "<div class='alert alert-danger'>Upload directory does not exist: $target_dir</div>";
exit;
mkdir($target_dir, 0755, true);
}
if (!is_writable($target_dir)) {
echo "<div class='alert alert-danger'>Upload directory is not writable: $target_dir</div>";
exit;
}
if (move_uploaded_file($file['tmp_name'], $target_file)) {
chmod($target_file, 0664);
if (move_uploaded_file($_FILES['pop_file']['tmp_name'], $target_file)) {
chmod($target_file, 0644);
// Update EFT and booking status
$payment_type = $_POST['payment_type'] ?? 'booking';
@@ -61,42 +56,47 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt1 = $conn->prepare("UPDATE efts SET status = 'PROCESSING' WHERE eft_id = ?");
$stmt1->bind_param("s", $eft_id);
$stmt1->execute();
$stmt1->close();
// Update membership fee status
$stmt = $conn->prepare("UPDATE membership_fees SET payment_status = 'PROCESSING' WHERE payment_id = ?");
$stmt->bind_param("s", $eft_id);
$stmt->execute();
$stmt->close();
} else {
// Update EFT and booking status
$stmt1 = $conn->prepare("UPDATE efts SET status = 'PROCESSING' WHERE eft_id = ?");
$stmt1->bind_param("s", $eft_id);
$stmt1->execute();
$stmt1->close();
$stmt2 = $conn->prepare("UPDATE bookings SET status = 'PROCESSING' WHERE eft_id = ?");
$stmt2->bind_param("s", $eft_id);
$stmt2->execute();
$stmt2->close();
}
// Send notification email using sendPOP()
$fullname = getFullName($user_id); // Assuming this returns "First Last"
$fullname = getFullName($user_id);
$eftDetails = getEFTDetails($eft_id);
$modified = str_replace(' ', '_', $eft_id);
if ($eftDetails) {
$amount = "R" . number_format($eftDetails['amount'], 2);
$description = $eftDetails['description'];
} else {
$amount = "R0.00";
$description = "Payment"; // fallback
$description = "Payment";
}
if (sendPOP($fullname, $modified, $amount, $description)) {
if (sendPOP($fullname, $randomFilename, $amount, $description)) {
$_SESSION['message'] = "Thank you! Your payment proof has been uploaded and notification sent.";
} else {
$_SESSION['message'] = "Payment uploaded, but notification email could not be sent.";
}
// Log the action
auditLog($user_id, 'POP_UPLOAD', 'efts', $eft_id, ['filename' => $randomFilename, 'payment_type' => $payment_type]);
header("Location: bookings.php");
exit;
@@ -158,7 +158,7 @@ if (!empty($bannerImages)) {
<?php if (count($items) > 0) {?>
<form enctype="multipart/form-data" method="POST">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<div class="row mt-35">
<ul class="tickets clearfix">
<li>

View File

@@ -1,7 +1,9 @@
<?php
session_start();
include_once('connection.php'); // DB connection file
include_once('connection.php');
require_once("functions.php");
require_once("env.php");
$response = array('status' => 'error', 'message' => 'Something went wrong');
// Check if the user is logged in
@@ -14,50 +16,60 @@ if (!isset($_SESSION['user_id'])) {
$user_id = $_SESSION['user_id'];
// Handle profile picture upload
if (isset($_FILES['profile_picture']['name']) && $_FILES['profile_picture']['error'] == 0) {
if (isset($_FILES['profile_picture']) && $_FILES['profile_picture']['error'] != UPLOAD_ERR_NO_FILE) {
// Validate file using hardened validation function
$validationResult = validateFileUpload($_FILES['profile_picture'], 'profile_picture');
if ($validationResult === false) {
$response['message'] = 'Invalid file. Only JPG, JPEG, PNG, GIF, and WEBP images under 5MB are allowed.';
echo json_encode($response);
exit();
}
// Extract validated filename
$randomFilename = $validationResult['filename'];
$target_dir = "assets/images/pp/";
$imageFileType = strtolower(pathinfo($_FILES["profile_picture"]["name"], PATHINFO_EXTENSION));
$target_file = $target_dir . $randomFilename;
// Set the target file as $user_id.EXT (where EXT is the image's extension)
$target_file = $target_dir . $user_id . '.' . $imageFileType;
$filename = $user_id . '.' . $imageFileType;
// Ensure upload directory exists and is writable
if (!is_dir($target_dir)) {
mkdir($target_dir, 0755, true);
}
// Check if the uploaded file is an image
$check = getimagesize($_FILES["profile_picture"]["tmp_name"]);
if ($check !== false) {
// Limit the file size to 5MB
if ($_FILES["profile_picture"]["size"] > 5000000) {
$response['message'] = 'Sorry, your file is too large.';
if (!is_writable($target_dir)) {
$response['message'] = 'Upload directory is not writable.';
echo json_encode($response);
exit();
}
// Allow certain file formats
$allowed_types = array("jpg", "jpeg", "png", "gif");
if (!in_array($imageFileType, $allowed_types)) {
$response['message'] = 'Sorry, only JPG, JPEG, PNG & GIF files are allowed.';
echo json_encode($response);
exit();
}
// Move the uploaded file
if (move_uploaded_file($_FILES['profile_picture']['tmp_name'], $target_file)) {
// Set secure file permissions (readable but not executable)
chmod($target_file, 0644);
// Move the uploaded file to the server and name it as $user_id.EXT
if (move_uploaded_file($_FILES["profile_picture"]["tmp_name"], $target_file)) {
// Update the profile picture path in the database
$sql = "UPDATE users SET profile_pic = ? WHERE user_id = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
$response['message'] = 'Database error.';
echo json_encode($response);
exit();
}
$stmt->bind_param("si", $target_file, $user_id);
if ($stmt->execute()) {
$_SESSION['profile_pic'] = $target_file;
$response['status'] = 'success';
$response['message'] = 'Profile picture updated successfully';
// Log the action
auditLog($user_id, 'PROFILE_PIC_UPLOAD', 'users', $user_id, ['filename' => $randomFilename]);
} else {
$response['message'] = 'Failed to update profile picture in the database';
}
$stmt->close();
} else {
$response['message'] = 'Sorry, there was an error uploading your file.';
}
} else {
$response['message'] = 'File is not an image.';
$response['message'] = 'Failed to move uploaded file.';
}
} else {
$response['message'] = 'No file uploaded or file error.';