From 3f1e06479de4f1886708345b78e2e8878d602657 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 12:22:02 -0400 Subject: [PATCH] Strip EXIF/GPS metadata from image uploads (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/upload_attachment.php did a raw move_uploaded_file() with zero image processing. A photo attached from a phone retained embedded EXIF, including GPS coordinates, and download_attachment.php streams the file byte-for-byte back to any user with ticket visibility — for an infrastructure company, this could leak a data center or office's precise physical location through a routine ticket photo, especially on Confidential-visibility tickets whose whole point is restricting exactly this kind of detail. Added stripImageMetadata(): decodes and re-encodes JPEG/PNG/GIF/WebP uploads via GD, which drops EXIF chunks that aren't part of the pixel data. Best-effort — leaves the file untouched on any failure (corrupt image, unsupported format, GD unavailable, or an oversized decoded pixel count guarding against a decompression-bomb-style crafted image) rather than blocking the upload. Verified with a real GPS-tagged JPEG (generated via piexif) and a GD/PHP harness: GPS EXIF is gone after stripping, the image stays valid and correctly sized, PNG alpha transparency is preserved, and corrupt files / non-image MIME types are left byte-for-byte unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- api/upload_attachment.php | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) 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);