Skip to content

feat(mime): add CactiMime helpers and validate package_import uploads#7074

Closed
somethingwithproof wants to merge 6 commits into
Cacti:developfrom
somethingwithproof:feat/develop-symfony-mime-validation
Closed

feat(mime): add CactiMime helpers and validate package_import uploads#7074
somethingwithproof wants to merge 6 commits into
Cacti:developfrom
somethingwithproof:feat/develop-symfony-mime-validation

Conversation

@somethingwithproof

Copy link
Copy Markdown
Contributor

Second of three small PRs introducing Symfony components on develop. This one adds symfony/mime and content-based MIME validation for file uploads, with package_import.php as the pilot.

What it adds

  • lib/CactiMime.php — wrapper around Symfony\Component\Mime\MimeTypes with two static methods: detect(string $path): string and validate(string $path, array $allowed_mimes, bool $strict = false): bool. In strict mode, validation fails closed when finfo_open is unavailable (libmagic missing) rather than degrading silently to extension-only matching.
  • One-time cacti_log() warning when libmagic is missing, so operators see the degradation in the log.

Pilot call site

package_import.php now runs CactiMime::validate($path, $allowed, true) before any file content is read. Allowlist is application/zip, application/gzip, application/x-gzip, application/xml, application/x-xml, text/xml — covers the form's accept='.xml.gz' (gzipped XML packages) and the legacy .xml path.

The existing extension and signature checks stay in place. This is defense in depth, not replacement.

Why

A .zip-renamed PHP file passes the existing pathinfo($file, PATHINFO_EXTENSION) check but fails MIME content sniffing. Recent advisory work in package_import.php was 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 materialize valid.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/process PR: composer.json change only, vendor refresh out of band.

Defense in depth

docs/security/mime-validation.md spells 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.

Copilot AI review requested due to automatic review settings April 28, 2026 03:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.php with detect() and validate() helpers (including strict fail-closed mode when finfo is unavailable).
  • Gate package_import.php uploads using CactiMime::validate(..., strict=true) before reading file contents.
  • Add unit tests/fixtures and security documentation for MIME validation, plus symfony/mime to composer.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.

Comment thread tests/Unit/PackageImportMimeTest.php Outdated
Comment on lines +33 to +38
/**
* @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'];
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread package_import.php
// 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'];

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment thread lib/CactiMime.php Outdated
Comment on lines +73 to +78
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;
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/Unit/CactiMimeTest.php Outdated
Comment on lines +73 to +79
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();
});

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
}
}
});

Copilot uses AI. Check for mistakes.
@somethingwithproof somethingwithproof force-pushed the feat/develop-symfony-mime-validation branch 2 times, most recently from 9c22bf5 to 2894f5b Compare April 28, 2026 04:18
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>
@somethingwithproof somethingwithproof force-pushed the feat/develop-symfony-mime-validation branch from 2894f5b to db9714b Compare April 28, 2026 04:59
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>
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

Consolidated into #7073 (Symfony Process + Mime helpers bundle). All commits cherry-picked over.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants