api/upload_attachment.php enforced a per-file size cap but nothing bounded the total number of attachments on a single ticket or their cumulative size over time — an authenticated low-privilege user could slowly fill the uploads/ disk by attaching many files across tickets, bounded only by the general rate limiter (which throttles request rate, not storage volume). Added MAX_ATTACHMENTS_PER_TICKET (50) and MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET (100MB) config defaults, enforced before move_uploaded_file() using AttachmentModel::getAttachmentCount() and getTotalSizeForTicket() — both already existed in the model with zero callers, apparently added for exactly this purpose but never wired in. Verified against a local MariaDB instance: with 3 existing 1MB attachments and a 3-attachment cap, the count check correctly rejects a 4th; with a 5MB total cap, a 2.5MB upload that would push the ticket over the limit is correctly rejected while a small one that fits is not. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
333 lines
11 KiB
PHP
333 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Upload Attachment API
|
|
*
|
|
* Handles file uploads for ticket attachments
|
|
*/
|
|
|
|
// Capture errors for debugging
|
|
ini_set('display_errors', 0);
|
|
error_reporting(E_ALL);
|
|
|
|
// Apply rate limiting (also starts session)
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Ensure session is started
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
/**
|
|
* Strip EXIF/metadata (including GPS) from an image file in place by
|
|
* decoding and re-encoding it via GD, which drops metadata chunks that
|
|
* aren't part of the pixel data. Best-effort: leaves the file untouched on
|
|
* any failure (corrupt image, unsupported format, GD unavailable) rather
|
|
* than blocking the upload — original bytes are what would have been stored
|
|
* anyway before this existed.
|
|
*
|
|
* download_attachment.php streams attachments back byte-for-byte to any user
|
|
* with ticket visibility, so an unstripped phone photo's embedded GPS data
|
|
* would otherwise leak a data center/office's physical location even on a
|
|
* Confidential-visibility ticket.
|
|
*/
|
|
function stripImageMetadata(string $path, string $mimeType): void
|
|
{
|
|
if (!extension_loaded('gd')) {
|
|
return;
|
|
}
|
|
|
|
// Guard against a decompression-bomb-style crafted image (small file,
|
|
// huge decoded pixel buffer) exhausting memory during decode.
|
|
$dims = @getimagesize($path);
|
|
if ($dims === false) {
|
|
return;
|
|
}
|
|
[$width, $height] = $dims;
|
|
if ($width * $height > 40_000_000) { // ~40 MP cap
|
|
return;
|
|
}
|
|
|
|
$loaders = [
|
|
'image/jpeg' => 'imagecreatefromjpeg',
|
|
'image/png' => 'imagecreatefrompng',
|
|
'image/gif' => 'imagecreatefromgif',
|
|
'image/webp' => 'imagecreatefromwebp',
|
|
];
|
|
$loader = $loaders[$mimeType] ?? null;
|
|
if ($loader === null || !function_exists($loader)) {
|
|
return;
|
|
}
|
|
|
|
$image = @$loader($path);
|
|
if ($image === false) {
|
|
return;
|
|
}
|
|
|
|
// Preserve transparency for formats that support it.
|
|
imagesavealpha($image, true);
|
|
imagealphablending($image, false);
|
|
|
|
$tmpPath = $path . '.tmp';
|
|
$saved = match ($mimeType) {
|
|
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
|
|
'image/png' => imagepng($image, $tmpPath, 6),
|
|
'image/gif' => imagegif($image, $tmpPath),
|
|
'image/webp' => imagewebp($image, $tmpPath, 90),
|
|
default => false,
|
|
};
|
|
imagedestroy($image);
|
|
|
|
if ($saved && file_exists($tmpPath)) {
|
|
rename($tmpPath, $path);
|
|
} elseif (file_exists($tmpPath)) {
|
|
unlink($tmpPath);
|
|
}
|
|
}
|
|
|
|
// Check authentication
|
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
ResponseHelper::unauthorized();
|
|
}
|
|
|
|
// Handle GET requests to list attachments
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$ticketId = $_GET['ticket_id'] ?? '';
|
|
|
|
if (empty($ticketId)) {
|
|
ResponseHelper::error('Ticket ID is required');
|
|
}
|
|
|
|
// Validate ticket ID format (positive integer)
|
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
|
ResponseHelper::error('Invalid ticket ID format');
|
|
}
|
|
|
|
try {
|
|
$conn = Database::getConnection();
|
|
$ticketModel = new TicketModel($conn);
|
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
|
ResponseHelper::notFound('Ticket not found');
|
|
}
|
|
|
|
$attachmentModel = new AttachmentModel($conn);
|
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
|
|
|
// Add formatted file size and icon to each attachment
|
|
foreach ($attachments as &$att) {
|
|
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
|
}
|
|
|
|
ResponseHelper::success(['attachments' => $attachments]);
|
|
} catch (Exception $e) {
|
|
ResponseHelper::serverError('Failed to load attachments');
|
|
}
|
|
}
|
|
|
|
// Only accept POST requests for uploads
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
ResponseHelper::error('Method not allowed', 405);
|
|
}
|
|
|
|
// Verify CSRF token
|
|
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
ResponseHelper::forbidden('Invalid CSRF token');
|
|
}
|
|
|
|
// Get ticket ID
|
|
$ticketId = $_POST['ticket_id'] ?? '';
|
|
if (empty($ticketId)) {
|
|
ResponseHelper::error('Ticket ID is required');
|
|
}
|
|
|
|
// Validate ticket ID format (positive integer)
|
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
|
ResponseHelper::error('Invalid ticket ID format');
|
|
}
|
|
|
|
// Verify user can access the ticket before accepting upload
|
|
$conn = Database::getConnection();
|
|
$ticketModel = new TicketModel($conn);
|
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
|
ResponseHelper::notFound('Ticket not found');
|
|
}
|
|
|
|
// Check if file was uploaded
|
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
|
ResponseHelper::error('No file uploaded');
|
|
}
|
|
|
|
$file = $_FILES['file'];
|
|
|
|
// Check for upload errors
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
$errorMessages = [
|
|
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
|
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
|
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
|
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
|
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
|
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
|
|
];
|
|
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
|
|
ResponseHelper::error($message);
|
|
}
|
|
|
|
// Check file size
|
|
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
|
|
if ($file['size'] > $maxSize) {
|
|
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
|
}
|
|
|
|
// Check per-ticket attachment count/storage quota — bounds an authenticated
|
|
// low-privilege user slowly filling the uploads/ disk across many tickets,
|
|
// which was previously bounded only by the request-rate limiter, not volume.
|
|
$attachmentModel = new AttachmentModel($conn);
|
|
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
|
|
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
|
|
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
|
|
}
|
|
|
|
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
|
|
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
|
|
ResponseHelper::error(
|
|
'This upload would exceed the ticket\'s total attachment size limit of '
|
|
. AttachmentModel::formatFileSize($maxTotalSize)
|
|
);
|
|
}
|
|
|
|
// Get MIME type
|
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
$mimeType = $finfo->file($file['tmp_name']);
|
|
|
|
// Validate file type
|
|
if (!AttachmentModel::isAllowedType($mimeType)) {
|
|
ResponseHelper::error('File type not allowed: ' . $mimeType);
|
|
}
|
|
|
|
// Create upload directory if it doesn't exist
|
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
|
if (!is_dir($uploadDir)) {
|
|
if (!mkdir($uploadDir, 0755, true)) {
|
|
ResponseHelper::serverError('Failed to create upload directory');
|
|
}
|
|
}
|
|
|
|
// Create ticket subdirectory — ticketId is validated as digits-only above
|
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
|
if (!is_dir($ticketDir)) {
|
|
if (!mkdir($ticketDir, 0755, true)) {
|
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
|
}
|
|
}
|
|
// Confirm resolved path stays within the upload root (defence-in-depth)
|
|
$resolvedTicketDir = realpath($ticketDir);
|
|
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
|
|
ResponseHelper::error('Invalid upload path');
|
|
}
|
|
|
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
|
$mimeToExt = [
|
|
'image/jpeg' => 'jpg', 'image/png' => 'png',
|
|
'image/gif' => 'gif', 'image/webp' => 'webp',
|
|
'application/pdf' => 'pdf',
|
|
'text/plain' => 'txt', 'text/csv' => 'csv',
|
|
'application/msword' => 'doc',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
'application/vnd.ms-excel' => 'xls',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
|
'application/zip' => 'zip',
|
|
'application/x-7z-compressed' => '7z',
|
|
'application/x-tar' => 'tar',
|
|
'application/gzip' => 'gz',
|
|
'application/json' => 'json',
|
|
'application/xml' => 'xml',
|
|
];
|
|
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
|
|
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
|
|
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
|
|
|
// Move uploaded file
|
|
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|
ResponseHelper::serverError('Failed to move uploaded file');
|
|
}
|
|
|
|
// Strip EXIF/GPS metadata from image uploads before it's ever served back
|
|
if (str_starts_with($mimeType, 'image/')) {
|
|
stripImageMetadata($targetPath, $mimeType);
|
|
}
|
|
|
|
// Sanitize original filename
|
|
$originalFilename = basename($file['name']);
|
|
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
|
if (empty($originalFilename)) {
|
|
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
|
|
}
|
|
|
|
// Save to database
|
|
try {
|
|
$attachmentId = $attachmentModel->addAttachment(
|
|
$ticketId,
|
|
$uniqueFilename,
|
|
$originalFilename,
|
|
$file['size'],
|
|
$mimeType,
|
|
$_SESSION['user']['user_id']
|
|
);
|
|
|
|
if (!$attachmentId) {
|
|
// Clean up file if database insert fails
|
|
unlink($targetPath);
|
|
ResponseHelper::serverError('Failed to save attachment record');
|
|
}
|
|
|
|
// Log the upload
|
|
$conn = Database::getConnection();
|
|
$auditLog = new AuditLogModel($conn);
|
|
$auditLog->log(
|
|
$_SESSION['user']['user_id'],
|
|
'attachment_upload',
|
|
'ticket_attachments',
|
|
(string)$attachmentId,
|
|
[
|
|
'ticket_id' => $ticketId,
|
|
'filename' => $originalFilename,
|
|
'size' => $file['size'],
|
|
'mime_type' => $mimeType
|
|
]
|
|
);
|
|
|
|
ResponseHelper::created([
|
|
'attachment_id' => $attachmentId,
|
|
'filename' => $originalFilename,
|
|
'file_size' => $file['size'],
|
|
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
|
'mime_type' => $mimeType,
|
|
'icon' => AttachmentModel::getFileIcon($mimeType),
|
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
|
'uploaded_at' => date('Y-m-d H:i:s')
|
|
], 'File uploaded successfully');
|
|
} catch (Exception $e) {
|
|
// Clean up file on error
|
|
if (file_exists($targetPath)) {
|
|
unlink($targetPath);
|
|
}
|
|
ResponseHelper::serverError('Failed to process attachment');
|
|
}
|