Skip to content

WireUpload rejects valid MP3 uploads ("File content does not match its extension"); no config way to add allowed MIME types #2287

Description

@outflux3

WireUpload rejects valid MP3 files: "File content does not match its extension" — and $config->fileContentTypes array values (per docblock) aren't honored

Short description

WireUpload::hasValidMimeType() rejects some valid .mp3 uploads with "File content does not match its extension", because finfo reports a MIME type other than audio/mpeg for certain MP3s (e.g. files with large ID3v2 tags / embedded cover art, or produced by particular encoders).

This affects normal InputfieldFile/InputfieldImage uploads (not just custom WireUpload usage), so editors can't upload legitimate audio.

The natural workaround — supplying additional allowed MIME types via $config->fileContentTypesdoes not work, even though hasValidMimeType()'s own docblock says array values are supported. The only config-level remedies are to remove the extension from fileContentTypes entirely (disabling the check) or replace its single MIME string.

Steps to reproduce

  1. Have a file field (e.g. a FieldtypeFile) that allows the mp3 extension.
  2. Upload a valid MP3 whose content finfo does not detect as audio/mpeg (common with classical/audiobook rips carrying large ID3v2 tags / embedded artwork).
  3. The upload is rejected in the field UI with: <filename> - File content does not match its extension.

Other MP3s in the same batch (detected as audio/mpeg) upload fine, so it's file-content dependent.

Confirm the detected type:

$finfo = finfo_open(FILEINFO_MIME_TYPE);
echo finfo_file($finfo, '/path/to/rejected.mp3'); // e.g. application/octet-stream (not audio/mpeg)

Expected

A valid MP3 with the .mp3 extension should upload to a field that permits mp3. Failing that, there should be a supported, documented config way to add accepted MIME types for an extension.

Actual

  • The file is rejected.
  • $config->fileContentTypes['mp3'] = ['audio/mpeg', 'audio/mp3', 'application/octet-stream'] (array) is not honored — see root cause below.

Concrete reproduction data

A real-world failing file: a valid MP3 (plays fine everywhere) beginning with an ID3 v2.3 header and carrying a ~1.4 MB ID3v2 tag (embedded cover art) before the first MPEG audio frame.

finfo results for the same file across PHP builds on one machine:

PHP build (finfo/libmagic) Detected MIME Result
PHP 8.3.30 (Apache/FastCGI — the one actually serving the site) application/octet-stream rejected
PHP 8.4 / 8.5 (CLI) audio/mpeg would pass
system PHP (CLI) audio/mpeg would pass
file --mime-type (system libmagic) application/octet-stream

So the identical, valid MP3 is accepted or rejected purely based on the PHP/libmagic version. The large leading ID3v2 tag appears to push the MPEG frame sync beyond some libmagic builds' detection window, so they fall back to application/octet-stream. Because the accepted MIME list for mp3 is hardcoded, there is no supported way to accommodate this.

Root cause

In wire/core/Tools/WireUpload/WireUpload.php:

  • saveUpload() calls hasValidMimeType($destination) and errors on failure:

    if(!$this->hasValidMimeType($destination)) {
        $fname = $this->wire()->sanitizer->name(basename($destination));
        $this->error("$fname - " . $this->_('File content does not match its extension'));
        ...
    }
  • hasValidMimeType()'s docblock advertises array values per extension:

    /**
     * @param array $mimeTypes Optionally specify [ 'ext' => 'type', 'ext' => [ 'type1', 'type2' ] ...
     */
    public function hasValidMimeType($filename, $mimeTypes = []) {
        if(empty($mimeTypes)) $mimeTypes = $this->wire()->config->fileContentTypes;
        ...
        // trim off the force download "+" used by $config->fileContentTypes
        foreach($mimeTypes as $key => $mimeType) {
            $mimeTypes[$key] = ltrim($mimeType, '+');   // ← ltrim() on an array = TypeError (PHP 8)
        }
        ...
        $allowedType = $mimeTypes[$extension];
        if(stripos($mimeType, $allowedType) === 0) return true;   // ← stripos() needle assumed string
        ...
    }

    So when a $config->fileContentTypes value is an array, ltrim() / stripos() throw (needle must be a string). In practice values must be single strings, contradicting the docblock.

  • The set of MIME types that can match multiple values is a hardcoded map, not extendable via config:

    $multiMimeTypes = [
        'svg'  => [ 'image/svg+xml', 'application/xml', 'text/xml' ],
        'docx' => [ 'application/vnd.openxmlformats-officedocument', 'application/zip' ],
        'xlsx' => [ 'application/vnd.openxmlformats-officedocument', 'application/zip' ],
        'zip'  => [ 'application/zip', 'application/x-zip' ],
        'mp3'  => [ 'audio/mpeg', 'audio/mp3' ],
    ];

    Because mp3 maps only to audio/mpeg / audio/mp3, any other finfo result (e.g. application/octet-stream) is rejected, and there's no supported way to add to this list.

Current workarounds (site config.php)

// Disable the content check for mp3 (extension allowlist still applies):
$fct = $config->fileContentTypes;
unset($fct['mp3']);
$config->fileContentTypes = $fct;

// …or replace the single value (loses audio/mpeg unless left to the hardcoded multiMimeTypes):
// $config->fileContentTypes = array_merge($config->fileContentTypes, ['mp3' => 'application/octet-stream']);

Both are blunt: the first removes the safeguard entirely; the second can't express "these several MIME types are OK."

Suggested fix

  1. Honor array values in $config->fileContentTypes / hasValidMimeType($mimeTypes) as the docblock already promises — normalize the ltrim('+') step and the matching loop to handle a string or an array of allowed types per extension. This lets sites self-serve:

    $config->fileContentTypes = array_merge($config->fileContentTypes, [
        'mp3' => ['audio/mpeg', 'audio/mp3', 'audio/x-mpeg', 'application/octet-stream'],
    ]);
  2. And/or broaden the hardcoded mp3 list to include commonly-reported valid variants (finfo/libmagic differs across platforms).

Fix #1 is the more general win — it aligns the code with the documented behavior and covers every extension, not just mp3.

Environment

  • ProcessWire: 3.0.268 (logic is present in 3.0.x generally)
  • PHP: 8.3.30 on the serving stack (Apache + FastCGI); reproduced where finfo returns application/octet-stream for a valid MP3. Other local PHP builds (8.4 / 8.5 CLI) return audio/mpeg for the same file — i.e. the outcome is libmagic-version dependent, not ProcessWire-version dependent.
  • OS: macOS
  • Reproducible with any valid MP3 that carries a large leading ID3v2 tag (embedded artwork) on a PHP/libmagic build that reports it as application/octet-stream.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions