feat(mime): add CactiMime helpers and validate package_import uploads#7074
feat(mime): add CactiMime helpers and validate package_import uploads#7074somethingwithproof wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces content-based MIME detection/validation via Symfony’s symfony/mime and applies it to package_import.php as a pilot hardening step for upload validation in Cacti.
Changes:
- Add
lib/CactiMime.phpwithdetect()andvalidate()helpers (including strict fail-closed mode whenfinfois unavailable). - Gate
package_import.phpuploads usingCactiMime::validate(..., strict=true)before reading file contents. - Add unit tests/fixtures and security documentation for MIME validation, plus
symfony/mimetocomposer.json.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
lib/CactiMime.php |
New helper wrapping Symfony MIME guessing + allowlist validation and finfo-missing warning. |
package_import.php |
Enforces MIME allowlist check on uploaded package content before processing. |
composer.json |
Adds symfony/mime dependency. |
tests/Unit/CactiMimeTest.php |
Unit tests for detection/validation behavior. |
tests/Unit/PackageImportMimeTest.php |
Pilot-focused tests for package import allowlist scenarios. |
tests/Unit/fixtures/mime/build_fixtures.php |
Generates binary fixtures (zip/gz/opaque) in temp dir for tests. |
tests/Unit/fixtures/mime/valid.xml |
Plain XML fixture used by tests. |
docs/security/mime-validation.md |
Documents how/where to apply MIME validation and strict-mode rationale. |
| /** | ||
| * @return string[] mirrors $allowed_mimes from package_import.php form_save() | ||
| */ | ||
| function cacti_mime_test_allowlist() : array { | ||
| return ['application/zip', 'application/x-xml', 'application/xml', 'text/xml', 'application/gzip', 'application/x-gzip']; | ||
| } |
There was a problem hiding this comment.
This test “mirrors” the allowlist defined in package_import.php, but it’s duplicated as a separate array here, so future allowlist changes can silently diverge from the production code. Consider centralizing the allowlist (e.g., a constant/helper in package_import.php or CactiMime) and having both the upload handler and this test reference the same source of truth.
| // any extension-based or signature handling sees it. Strict mode | ||
| // fails closed if libmagic is unavailable. | ||
| $allowed_mimes = ['application/zip', 'application/x-xml', 'application/xml', 'text/xml', 'application/gzip', 'application/x-gzip']; | ||
|
|
There was a problem hiding this comment.
In strict mode, a missing finfo extension will cause CactiMime::validate(..., true) to fail closed, but the UI error message shown here is identical to the “bad upload” case. Consider detecting !function_exists('finfo_open') at the call site (or exposing a reason from CactiMime) to show a clearer operator-facing message (e.g., server missing fileinfo/libmagic) while still rejecting the upload.
| if (!function_exists('finfo_open')) { | |
| raise_message('import_mime_reject', __('The uploaded file could not be validated because the PHP fileinfo extension (libmagic) is not available on this server.'), MESSAGE_LEVEL_ERROR); | |
| header('Location: package_import.php'); | |
| exit; | |
| } |
| public static function validate(string $path, array $allowed_mimes, bool $strict = false) : bool { | ||
| if ($strict && !function_exists('finfo_open')) { | ||
| self::warn_if_finfo_missing(); | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
validate() defaults $strict to false, which makes the safest behavior opt-in. Since this helper is intended for upload boundaries, consider making strict mode the default (fail closed when finfo is unavailable) and requiring callers that truly want extension-only fallback to explicitly pass false. This reduces the chance of a future call site accidentally omitting strict mode.
| test()->markTestSkipped('finfo not present; strict-mode close-on-missing is exercised in a dedicated test.'); | ||
| } | ||
|
|
||
| $fx = cacti_mime_build_fixtures(); | ||
| expect(CactiMime::validate($fx['zip'], ['application/zip'], true))->toBeTrue(); | ||
| }); | ||
|
|
There was a problem hiding this comment.
The strict-mode behavior when finfo/libmagic is missing isn’t actually tested here: this test only covers the finfo-present path, and the skip message references a “dedicated test” that doesn’t exist in the suite. Add a test that exercises validate(..., strict=true) with finfo unavailable (e.g., via a subprocess run with fileinfo disabled), or adjust the test/skip message so coverage claims match reality.
| test()->markTestSkipped('finfo not present; strict-mode close-on-missing is exercised in a dedicated test.'); | |
| } | |
| $fx = cacti_mime_build_fixtures(); | |
| expect(CactiMime::validate($fx['zip'], ['application/zip'], true))->toBeTrue(); | |
| }); | |
| test()->markTestSkipped('finfo not present in this process; strict-mode close-on-missing is covered by the subprocess test below.'); | |
| } | |
| $fx = cacti_mime_build_fixtures(); | |
| expect(CactiMime::validate($fx['zip'], ['application/zip'], true))->toBeTrue(); | |
| }); | |
| test('validate(strict=true) fails closed when finfo functions are unavailable', function () { | |
| $fx = cacti_mime_build_fixtures(); | |
| $scriptPath = tempnam(sys_get_temp_dir(), 'cacti-mime-'); | |
| $script = <<<'PHP' | |
| <?php | |
| require_once __DIR__ . '/../../include/vendor/autoload.php'; | |
| require_once __DIR__ . '/../../lib/CactiMime.php'; | |
| require_once __DIR__ . '/fixtures/mime/build_fixtures.php'; | |
| if (!function_exists('cacti_log')) { | |
| function cacti_log(mixed $string, bool $output = false, string $environ = 'CMDPHP', mixed $level = '') : bool { | |
| return true; | |
| } | |
| } | |
| $file = $argv[1]; | |
| var_export(CactiMime::validate($file, ['application/zip'], true)); | |
| PHP; | |
| $script = str_replace( | |
| [ | |
| '__DIR__ . \'/../../include/vendor/autoload.php\'', | |
| '__DIR__ . \'/../../lib/CactiMime.php\'', | |
| '__DIR__ . \'/fixtures/mime/build_fixtures.php\'', | |
| ], | |
| [ | |
| var_export(__DIR__ . '/../../include/vendor/autoload.php', true), | |
| var_export(__DIR__ . '/../../lib/CactiMime.php', true), | |
| var_export(__DIR__ . '/fixtures/mime/build_fixtures.php', true), | |
| ], | |
| $script | |
| ); | |
| file_put_contents($scriptPath, $script); | |
| try { | |
| $command = escapeshellarg(PHP_BINARY) | |
| . ' -d disable_functions=finfo_open,finfo_file,finfo_close ' | |
| . escapeshellarg($scriptPath) | |
| . ' ' | |
| . escapeshellarg($fx['zip']) | |
| . ' 2>&1'; | |
| $output = []; | |
| $status = 0; | |
| exec($command, $output, $status); | |
| expect($status)->toBe(0); | |
| expect(trim(implode("\n", $output)))->toBe('false'); | |
| } finally { | |
| if (is_file($scriptPath)) { | |
| unlink($scriptPath); | |
| } | |
| } | |
| }); |
9c22bf5 to
2894f5b
Compare
Routes uploaded-file MIME detection through Symfony Mime so a content-type allowlist can be enforced on top of the existing extension and signature checks. package_import.php now rejects uploads whose detected MIME is not in the allowlist (zip, gzip, xml variants) before any further processing. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2894f5b to
db9714b
Compare
Pest tests pin every data boundary CactiMime crosses (content-not-name detect, browser type ignored, allowlist accept/reject, strict-mode fail-closed). infection.json5 scopes mutation to lib/CactiMime.php with a 70% MSI floor. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
package_import.php has no decompression step; accepting gzip uploads would only fail downstream with a confusing parse error. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Reverts the earlier gzip removal: import.php opens uploads via compress.zlib:// so gzipped XML is the primary distribution format. Adds LIBXML_NONET to simplexml_load_string calls as defense in depth. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
|
Consolidated into #7073 (Symfony Process + Mime helpers bundle). All commits cherry-picked over. |
Second of three small PRs introducing Symfony components on develop. This one adds
symfony/mimeand content-based MIME validation for file uploads, withpackage_import.phpas the pilot.What it adds
lib/CactiMime.php— wrapper aroundSymfony\Component\Mime\MimeTypeswith two static methods:detect(string $path): stringandvalidate(string $path, array $allowed_mimes, bool $strict = false): bool. In strict mode, validation fails closed whenfinfo_openis unavailable (libmagic missing) rather than degrading silently to extension-only matching.cacti_log()warning when libmagic is missing, so operators see the degradation in the log.Pilot call site
package_import.phpnow runsCactiMime::validate($path, $allowed, true)before any file content is read. Allowlist isapplication/zip,application/gzip,application/x-gzip,application/xml,application/x-xml,text/xml— covers the form'saccept='.xml.gz'(gzipped XML packages) and the legacy.xmlpath.The existing extension and signature checks stay in place. This is defense in depth, not replacement.
Why
A
.zip-renamed PHP file passes the existingpathinfo($file, PATHINFO_EXTENSION)check but fails MIME content sniffing. Recent advisory work inpackage_import.phpwas about precisely this gap. Centralizing detection makes future upload sites (theme upload, plugin install, color template import) cheap to harden.Tests
tests/Unit/CactiMimeTest.php— Pest. Covers detect for ZIP / XML / unknown bytes, validate accept and reject paths, strict-mode behavior.tests/Unit/PackageImportMimeTest.php— drives the validation against synthesized inputs (mismatched extension+content, real ZIP, XML disguised as ZIP).tests/Unit/fixtures/mime/build_fixtures.php— runtime generator for the binary fixtures (kept the repo text-only). Tests call this on demand to materializevalid.zip,valid.xml.gz,unknown.bin.E2E coverage was scoped out — the Playwright harness on develop only contains a smoke test, no upload-flow infrastructure. A follow-up can add an upload-form spec when the harness gains a docker-compose stack.
Vendor
Same policy as the sibling
symfony/processPR:composer.jsonchange only, vendor refresh out of band.Defense in depth
docs/security/mime-validation.mdspells out that this is necessary but not sufficient. ZIP-content scans, path traversal validation, XML XXE hardening still apply. The MIME gate is one layer; the existing layers stay.