Strip EXIF/GPS metadata from image uploads (#97)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user