From 5deb02450fa0ba87a694b27d423953b8eda91800 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 21:14:54 +0200 Subject: [PATCH] Validate attachment child MIME metadata shape Report empty or non-string generated-size and backup-size MIME metadata through the built-in WordPress upload validator instead of treating malformed values as MIME drift. --- scripts/cow/merge.php | 34 +++++++++++++++++++++++++-- tests/cow/media_validator.php | 43 ++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/cow/merge.php b/scripts/cow/merge.php index 5120f0bb..2521ba6c 100644 --- a/scripts/cow/merge.php +++ b/scripts/cow/merge.php @@ -10730,7 +10730,22 @@ function cow_merge_wordpress_attachment_upload_issues(string $target_db, string 'actual_filesize' => (int)filesize($size_absolute_path), ], [$size_path]); } - $declared_size_mime_type = array_key_exists('mime-type', $size) ? strtolower((string)$size['mime-type']) : null; + $declared_size_mime_type = null; + if (array_key_exists('mime-type', $size)) { + if (!is_string($size['mime-type']) || trim($size['mime-type']) === '') { + $record_issue($issues, $attachment_id, $post_title, 'plugin-wp-attachment-metadata-invalid-shape', 'attachment generated-size MIME type is empty or not a string', [ + 'field' => '_wp_attachment_metadata.sizes.' . (string)$size_name . '.mime-type', + 'role' => 'generated-size-mime-type', + 'size' => (string)$size_name, + 'attached_file' => $attached_file_raw, + 'generated_file' => (string)$size['file'], + 'mime_type' => is_scalar($size['mime-type']) || $size['mime-type'] === null ? $size['mime-type'] : get_debug_type($size['mime-type']), + 'value_type' => get_debug_type($size['mime-type']), + ], [$size_path]); + } else { + $declared_size_mime_type = strtolower((string)$size['mime-type']); + } + } $expected_size_mime_type = cow_merge_wordpress_expected_upload_mime_type($size_path); if ($declared_size_mime_type !== null && $expected_size_mime_type !== null && $declared_size_mime_type !== $expected_size_mime_type) { $record_issue($issues, $attachment_id, $post_title, 'plugin-wp-attachment-upload-mime-drift', 'attachment generated-size MIME type does not match the generated file extension', [ @@ -10849,7 +10864,22 @@ function cow_merge_wordpress_attachment_upload_issues(string $target_db, string 'actual_filesize' => (int)filesize($backup_absolute_path), ], [$backup_path]); } - $declared_backup_mime_type = array_key_exists('mime-type', $backup) ? strtolower((string)$backup['mime-type']) : null; + $declared_backup_mime_type = null; + if (array_key_exists('mime-type', $backup)) { + if (!is_string($backup['mime-type']) || trim($backup['mime-type']) === '') { + $record_issue($issues, $attachment_id, $post_title, 'plugin-wp-attachment-metadata-invalid-shape', 'attachment backup-size MIME type is empty or not a string', [ + 'field' => '_wp_attachment_metadata.backup_sizes.' . (string)$backup_name . '.mime-type', + 'role' => 'backup-size-mime-type', + 'backup_size' => (string)$backup_name, + 'attached_file' => $attached_file_raw, + 'backup_file' => (string)$backup['file'], + 'mime_type' => is_scalar($backup['mime-type']) || $backup['mime-type'] === null ? $backup['mime-type'] : get_debug_type($backup['mime-type']), + 'value_type' => get_debug_type($backup['mime-type']), + ], [$backup_path]); + } else { + $declared_backup_mime_type = strtolower((string)$backup['mime-type']); + } + } $expected_backup_mime_type = cow_merge_wordpress_expected_upload_mime_type($backup_path); if ($declared_backup_mime_type !== null && $expected_backup_mime_type !== null && $declared_backup_mime_type !== $expected_backup_mime_type) { $record_issue($issues, $attachment_id, $post_title, 'plugin-wp-attachment-upload-mime-drift', 'attachment backup-size MIME type does not match the backup file extension', [ diff --git a/tests/cow/media_validator.php b/tests/cow/media_validator.php index e7c82817..84257243 100644 --- a/tests/cow/media_validator.php +++ b/tests/cow/media_validator.php @@ -1003,6 +1003,8 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ write_test_file($source_root . '/wp-content/uploads/2026/05/source-content-mime-drift.jpg', "%PDF-1.4 source content MIME drift bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-mime.jpg', "source generated MIME original bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-mime-thumb.png', "source generated MIME thumb bytes\n"); + write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-mime-empty.jpg', "source generated empty MIME original bytes\n"); + write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-mime-empty-thumb.png', "source generated empty MIME thumb bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-content-mime.jpg', "source generated content MIME original bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-content-mime-thumb.jpg', "%PDF-1.4 generated content MIME drift bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-generated-dimensions.jpg', "source invalid generated dimensions original bytes\n"); @@ -1021,6 +1023,8 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-filesize-original.jpg', "source backup filesize original bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-mime-current.jpg', "source backup MIME current bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-mime-original.png', "source backup MIME original bytes\n"); + write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-mime-empty-current.jpg', "source backup empty MIME current bytes\n"); + write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-mime-empty-original.png', "source backup empty MIME original bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-content-mime-current.jpg', "source backup content MIME current bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-content-mime-original.jpg', "%PDF-1.4 backup content MIME drift bytes\n"); write_test_file($source_root . '/wp-content/uploads/2026/05/source-backup-dimensions-current.jpg', "source backup dimensions current bytes\n"); @@ -1222,6 +1226,19 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ ], ], ]); + $generated_mime_empty_id = insert_attachment($db, 'Source media empty generated MIME type', '2026/05/source-generated-mime-empty.jpg', [ + 'file' => '2026/05/source-generated-mime-empty.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-generated-mime-empty-thumb.png', + 'width' => 150, + 'height' => 150, + 'mime-type' => '', + ], + ], + ]); $generated_content_mime_drift_id = insert_attachment($db, 'Source media generated content MIME type drift', '2026/05/source-generated-content-mime.jpg', [ 'file' => '2026/05/source-generated-content-mime.jpg', 'width' => 640, @@ -1391,6 +1408,20 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ ], 'sizes' => [], ]); + $backup_mime_empty_id = insert_attachment($db, 'Source media empty backup MIME type', '2026/05/source-backup-mime-empty-current.jpg', [ + 'file' => '2026/05/source-backup-mime-empty-current.jpg', + 'width' => 640, + 'height' => 480, + 'backup_sizes' => [ + 'full-orig' => [ + 'file' => 'source-backup-mime-empty-original.png', + 'width' => 1200, + 'height' => 900, + 'mime-type' => '', + ], + ], + 'sizes' => [], + ]); $backup_content_mime_drift_id = insert_attachment($db, 'Source media backup content MIME drift', '2026/05/source-backup-content-mime-current.jpg', [ 'file' => '2026/05/source-backup-content-mime-current.jpg', 'width' => 640, @@ -1588,7 +1619,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ assert_same($result['status'], 'completed_with_conflicts', 'media validator holds incomplete generated-size metadata for review'); assert_same((int)($result['plugin_validators'] ?? 0), 1, 'media validator is discovered from mu-plugins during merge'); - assert_same((int)($result['plugin_validator_conflicts'] ?? 0), 118, 'media validator records missing required metadata, invalid metadata, invalid shapes, dimensions, image metadata, filesize and MIME drift, invalid file entries, generated-size, original-image, backup-size, missing-file, metadata-file drift, unsafe path, duplicate upload conflicts, and built-in WordPress upload conflicts'); + assert_same((int)($result['plugin_validator_conflicts'] ?? 0), 122, 'media validator records missing required metadata, invalid metadata, invalid shapes, dimensions, image metadata, filesize and MIME drift, invalid file entries, generated-size, original-image, backup-size, missing-file, metadata-file drift, unsafe path, duplicate upload conflicts, and built-in WordPress upload conflicts'); assert_same( scalar($target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $attachment_id AND meta_key = '_wp_attached_file'"), '2026/05/source-generated-missing-file-key.jpg', @@ -1680,7 +1711,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ 'records' => 'conflicts', 'conflict_type' => 'plugin-wp-attachment-metadata-invalid-shape', ]); - assert_same(count($invalid_shape_audit['conflicts']), 12, 'media validator exposes malformed image_meta, generated-size, original_image, and backup-size metadata shapes as plugin-scoped audit conflicts'); + assert_same(count($invalid_shape_audit['conflicts']), 14, 'media validator exposes malformed image_meta, generated-size, original_image, and backup-size metadata shapes as plugin-scoped audit conflicts'); $invalid_shape_payloads = array_map( fn($conflict) => cow_merge_decode_payload_json((string)$conflict['chosen_payload'], 'media validator invalid-shape payload'), $invalid_shape_audit['conflicts'] @@ -1697,6 +1728,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ '_wp_attachment_metadata.backup_sizes.full-orig.dimensions', '_wp_attachment_metadata.backup_sizes.full-orig.file', '_wp_attachment_metadata.backup_sizes.full-orig.file', + '_wp_attachment_metadata.backup_sizes.full-orig.mime-type', '_wp_attachment_metadata.dimensions', '_wp_attachment_metadata.image_meta', '_wp_attachment_metadata.original_image', @@ -1705,6 +1737,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ '_wp_attachment_metadata.sizes.thumbnail.dimensions', '_wp_attachment_metadata.sizes.thumbnail.file', '_wp_attachment_metadata.sizes.thumbnail.file', + '_wp_attachment_metadata.sizes.thumbnail.mime-type', ], 'media validator invalid-shape audit identifies every malformed metadata field'); assert_true( in_array([], $invalid_shape_files_by_field['_wp_attachment_metadata.sizes.thumbnail.file'] ?? [], true), @@ -1721,10 +1754,12 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ assert_same($invalid_shape_files_by_field['_wp_attachment_metadata.backup_sizes.full-orig.dimensions'][0] ?? null, ['wp-content/uploads/2026/05/source-backup-dimensions-original.jpg'], 'media validator invalid-shape audit exposes the real backup upload path for invalid dimensions'); $invalid_shape_payload_preview = implode("\n", array_map(fn($payload) => json_encode($payload, JSON_UNESCAPED_SLASHES), $invalid_shape_payloads)); assert_true(str_contains($invalid_shape_payload_preview, (string)$generated_subdir_id), 'built-in WordPress upload invalid-shape audit includes the generated subdir attachment ID'); + assert_true(str_contains($invalid_shape_payload_preview, (string)$generated_mime_empty_id), 'built-in WordPress upload invalid-shape audit includes the empty generated MIME attachment ID'); assert_true(str_contains($invalid_shape_payload_preview, (string)$image_meta_drift_id), 'built-in WordPress upload invalid-shape audit includes the malformed image_meta attachment ID'); assert_true(str_contains($invalid_shape_payload_preview, (string)$original_image_empty_id), 'built-in WordPress upload invalid-shape audit includes the empty original_image attachment ID'); assert_true(str_contains($invalid_shape_payload_preview, (string)$original_image_subdir_id), 'built-in WordPress upload invalid-shape audit includes the original_image subdir attachment ID'); assert_true(str_contains($invalid_shape_payload_preview, (string)$backup_subdir_id), 'built-in WordPress upload invalid-shape audit includes the backup subdir attachment ID'); + assert_true(str_contains($invalid_shape_payload_preview, (string)$backup_mime_empty_id), 'built-in WordPress upload invalid-shape audit includes the empty backup MIME attachment ID'); $original_dimension_audit = cow_merge_audit_report($metadata, (int)$result['run_id'], 10, [ 'scope' => 'plugin', @@ -1872,7 +1907,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ 'records' => 'conflicts', 'conflict_type' => 'plugin-wp-media-mime-drift', ]); - assert_same(count($mime_audit['conflicts']), 8, 'media validator exposes image, AVIF, PDF, content-signature, generated-size, and backup-size MIME drift as plugin-scoped audit conflicts'); + assert_same(count($mime_audit['conflicts']), 10, 'media validator exposes image, AVIF, PDF, content-signature, generated-size, and backup-size MIME drift as plugin-scoped audit conflicts'); $mime_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $mime_audit['conflicts'])); assert_true(str_contains($mime_preview, 'source-mime-drift.jpg'), 'media validator MIME drift audit includes the affected attachment'); assert_true(str_contains($mime_preview, 'application/pdf'), 'media validator MIME drift audit includes the declared MIME type'); @@ -1888,6 +1923,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ assert_true(str_contains($mime_preview, (string)$content_mime_drift_id), 'media validator MIME drift audit includes the content-signature attachment ID'); assert_true(str_contains($mime_preview, 'image/png'), 'media validator MIME drift audit includes the expected generated MIME type'); assert_true(str_contains($mime_preview, (string)$generated_mime_drift_id), 'media validator MIME drift audit includes the generated-size attachment ID'); + assert_true(str_contains($mime_preview, (string)$generated_mime_empty_id), 'media validator MIME drift audit includes the empty generated MIME attachment ID'); assert_true(str_contains($mime_preview, (string)$generated_content_mime_drift_id), 'media validator MIME drift audit includes the generated content-signature attachment ID'); $generated_mime_recorded = false; $backup_mime_recorded = false; @@ -1928,6 +1964,7 @@ function insert_attachment_with_single_meta(SQLite3 $db, string $title, string $ assert_true($backup_mime_recorded, 'media validator MIME drift audit payload identifies the affected backup size'); assert_true($backup_content_mime_recorded, 'media validator MIME drift audit payload identifies backup bytes that disagree with the backup extension'); assert_true(str_contains($mime_preview, (string)$backup_mime_drift_id), 'media validator MIME drift audit includes the backup-size attachment ID'); + assert_true(str_contains($mime_preview, (string)$backup_mime_empty_id), 'media validator MIME drift audit includes the empty backup MIME attachment ID'); $wp_upload_mime_audit = cow_merge_audit_report($metadata, (int)$result['run_id'], 10, [ 'scope' => 'plugin',