diff --git a/api/upload_attachment.php b/api/upload_attachment.php index 93edf0d..6fa4e48 100644 --- a/api/upload_attachment.php +++ b/api/upload_attachment.php @@ -29,6 +29,73 @@ 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(); @@ -184,6 +251,11 @@ 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);