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->fileContentTypes — does 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
- Have a file field (e.g. a
FieldtypeFile) that allows the mp3 extension.
- Upload a valid MP3 whose content
finfo does not detect as audio/mpeg (common with classical/audiobook rips carrying large ID3v2 tags / embedded artwork).
- 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
-
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'],
]);
-
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.
WireUpload rejects valid MP3 files: "File content does not match its extension" — and
$config->fileContentTypesarray values (per docblock) aren't honoredShort description
WireUpload::hasValidMimeType()rejects some valid.mp3uploads with "File content does not match its extension", becausefinforeports a MIME type other thanaudio/mpegfor certain MP3s (e.g. files with large ID3v2 tags / embedded cover art, or produced by particular encoders).This affects normal
InputfieldFile/InputfieldImageuploads (not just customWireUploadusage), so editors can't upload legitimate audio.The natural workaround — supplying additional allowed MIME types via
$config->fileContentTypes— does not work, even thoughhasValidMimeType()'s own docblock says array values are supported. The only config-level remedies are to remove the extension fromfileContentTypesentirely (disabling the check) or replace its single MIME string.Steps to reproduce
FieldtypeFile) that allows themp3extension.finfodoes not detect asaudio/mpeg(common with classical/audiobook rips carrying large ID3v2 tags / embedded artwork).<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:
Expected
A valid MP3 with the
.mp3extension should upload to a field that permitsmp3. Failing that, there should be a supported, documented config way to add accepted MIME types for an extension.Actual
$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
ID3v2.3 header and carrying a ~1.4 MB ID3v2 tag (embedded cover art) before the first MPEG audio frame.finforesults for the same file across PHP builds on one machine:finfo/libmagic)application/octet-streamaudio/mpegaudio/mpegfile --mime-type(system libmagic)application/octet-streamSo 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 formp3is hardcoded, there is no supported way to accommodate this.Root cause
In
wire/core/Tools/WireUpload/WireUpload.php:saveUpload()callshasValidMimeType($destination)and errors on failure:hasValidMimeType()'s docblock advertises array values per extension:So when a
$config->fileContentTypesvalue 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:
Because
mp3maps only toaudio/mpeg/audio/mp3, any otherfinforesult (e.g.application/octet-stream) is rejected, and there's no supported way to add to this list.Current workarounds (site
config.php)Both are blunt: the first removes the safeguard entirely; the second can't express "these several MIME types are OK."
Suggested fix
Honor array values in
$config->fileContentTypes/hasValidMimeType($mimeTypes)as the docblock already promises — normalize theltrim('+')step and the matching loop to handle a string or an array of allowed types per extension. This lets sites self-serve:And/or broaden the hardcoded
mp3list 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
finforeturnsapplication/octet-streamfor a valid MP3. Other local PHP builds (8.4 / 8.5 CLI) returnaudio/mpegfor the same file — i.e. the outcome is libmagic-version dependent, not ProcessWire-version dependent.application/octet-stream.