Laravelでファイルアップロード時にバリデーションルール(mimes、mimetypes)を追加したらハマった話

こんにちは、エンジニアの @akase244 です。

Laravelのバリデーション機能を利用して、ファイルアップロード時に拡張子とMIMEタイプのチェックを追加してみたところ、特定のファイルで想定と異なる動きをしました。

そこで今回は、なぜそのような動きになるのかをLaravelのソースコードを読みながら調べてみたのでメモとして残しておきます。

ファイルアップロード時の要件

とある案件でファイルのアップロード機能を実装する際に、以下の拡張子のファイルのみをアップロードできるようにしてほしいというクライアントさんからの要望があり対応方法を検討しました。

  • avi
  • mov
  • mp4
  • webm
  • wmv

対応方針

今回はクライアント側(HTML)とサーバー側(Laravel)の両方で対応を行うことにしました。

クライアント側ではファイル選択タグに「accept属性」を追加して特定の拡張子のファイルのみを選択可能にします。 なお、ブラウザのバージョンによってはaccept属性に対応していないものもありますので、利用する場合は注意と検討が必要でしょう。

サーバー側ではLaravel標準のバリデーション機能を利用して、指定した拡張子とMIMEタイプ以外の場合に警告表示を行いアップロード可能なファイルの種類を制限します。

対応方法

「対応方針」を踏まえて以下のような対応を行いました。

クライアント側の対応

ファイル選択タグに「accept属性」を追加して、アップロードを許可する拡張子(ドットを付けた形式)をカンマ区切りの文字列で列挙します。

<input 
    type="file" 
    name="movie_file"
    accept=".avi,.mov,.mp4,.webm,.wmv"
    required="required">

サーバー側の対応

拡張子(mimes)とMIMEタイプ(mimetypes)のバリデーションルールを追加して、それぞれ対応する値を列挙します。
今回はFormRequestクラスを継承してrulesメソッド内に定義しました。

<?php
    ・
    ・
    public function rules()
    {
        $uploadableFileTypes = [
            'avi' => 'video/x-msvideo',
            'mov' => 'video/quicktime',
            'mp4' => 'video/mp4',
            'webm' => 'video/webm',
            'wmv' => 'video/x-ms-wmv',
        ];
        $extensionsRule = '|mimes:' 
            . implode(',', array_keys($uploadableFileTypes));
        $mimeTypesRule = '|mimetypes:' 
            . implode(',', array_values($uploadableFileTypes));
        return [
            'movie_file' => 'required'. $extensionsRule . $mimeTypesRule,
        ];
    }

発生した問題

バリデーション実行時に特定の拡張子の場合に以下の現象が発生し、バリデーションエラーとなりました。

  • 拡張子「mov」のファイルをアップロードするとvalidateMimesメソッドで拡張子が「qt」として判定される。
  • 拡張子「wmv」のファイルをアップロードするとvalidateMimesメソッドで拡張子が「asf」として判定される。
  • 拡張子「wmv」のファイルをアップロードするとvalidateMimetypesメソッドでMIMEタイプが「video/x-ms-asf」として判定される。

原因調査

前提

調査時に参照している各ソースコードのバージョンは以下のとおり。

拡張子「mov」の場合

それではLaravelのvalidateMimesメソッドのソースコードから順に見ていきます。

returnしている行の $value->guessExtension() というファイルの拡張子を推測する処理(メソッド名から判断)で文字列「qt」が返却されていることが予想されます。
guessExtensionメソッドはuseしている Symfony\Component\HttpFoundation\File\File 内に定義されていますので、次はそちらを見ていきます。

laravel/framework/blob/v6.5.2/src/Illuminate/Validation/Concerns/ValidatesAttributes.php [GitHub]

<?php
    ・
    ・
use Symfony\Component\HttpFoundation\File\File;
    ・
    ・
trait ValidatesAttributes
{
    ・
    ・
    /**
     * Validate the guessed extension of a file upload is in a set of file extensions.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @param  array   $parameters
     * @return bool
     */
    public function validateMimes($attribute, $value, $parameters)
    {
        if (! $this->isValidFileInstance($value)) {
            return false;
        }

        if ($this->shouldBlockPhpUpload($value, $parameters)) {
            return false;
        }

        return $value->getPath() !== '' && in_array($value->guessExtension(), $parameters);
    }




FileクラスのguessExtensionメソッドは同クラス内のgetMimeTypeメソッドで取得される値を元に処理を行っているため、先にgetMimeTypeメソッドを見てみます。
getMimeTypeメソッドはファイルのパスをMimeTypesクラスのguessMimeTypeメソッドに渡すことでMIMEタイプの値を推測して取得するようです。
取得したMIMEタイプの値をMimeTypesクラスのgetExtensionsメソッドに渡すことで配列が返却され、その配列の0番目の値をreturnしています。
このあたりはそれぞれのメソッドのDocコメントに詳しく解説されてますね。
ということで、次に見るべきは Symfony\Component\Mime\MimeTypes のgetExtensionsメソッドとguessMimeTypeメソッドのようです。

symfony/http-foundation/blob/v4.3.8/File/File.php [GitHub]

<?php
    ・
    ・
use Symfony\Component\Mime\MimeTypes;
    ・
    ・
class File extends \SplFileInfo
{
    ・
    ・
    /**
     * Returns the extension based on the mime type.
     *
     * If the mime type is unknown, returns null.
     *
     * This method uses the mime type as guessed by getMimeType()
     * to guess the file extension.
     *
     * @return string|null The guessed extension or null if it cannot be guessed
     *
     * @see MimeTypes
     * @see getMimeType()
     */
    public function guessExtension()
    {
        return MimeTypes::getDefault()->getExtensions($this->getMimeType())[0] ?? null;
    }

    /**
     * Returns the mime type of the file.
     *
     * The mime type is guessed using a MimeTypeGuesserInterface instance,
     * which uses finfo_file() then the "file" system binary,
     * depending on which of those are available.
     *
     * @return string|null The guessed mime type (e.g. "application/pdf")
     *
     * @see MimeTypes
     */
    public function getMimeType()
    {
        return MimeTypes::getDefault()->guessMimeType($this->getPathname());
    }




MimeTypesクラスは非常に情報量が多いので、じっくりと見ていきましょう。
先程のguessExtensionメソッドやgetMimeTypeメソッドの中で MimeTypes::getDefault() のように呼び出していましたので、まずはgetDefaultメソッドを見てみましょう。
getDefaultメソッドはMimeTypesクラス自身のインスタンスを生成して返しています。
インスタンス生成時に引数を渡していないためコンストラクタで $this->extensions 及び $this->mimeTypes については特に処理を行わずいずれも空の配列となります。また、コンストラクタ内でregisterGuesserメソッドが2回実行されていますが、これにより $this->guessers という配列に FileBinaryMimeTypeGuesserFileinfoMimeTypeGuesser のインスタンスが追加されるようです。

symfony/mime/blob/v4.3.8/MimeTypes.php [GitHub]

<?php
    ・
    ・
final class MimeTypes implements MimeTypesInterface
{
    private $extensions = [];
    private $mimeTypes = [];

    /**
     * @var MimeTypeGuesserInterface[]
     */
    private $guessers = [];
    private static $default;

    public function __construct(array $map = [])
    {
        foreach ($map as $mimeType => $extensions) {
            $this->extensions[$mimeType] = $extensions;

            foreach ($extensions as $extension) {
                $this->mimeTypes[$extension] = $mimeType;
            }
        }
        $this->registerGuesser(new FileBinaryMimeTypeGuesser());
        $this->registerGuesser(new FileinfoMimeTypeGuesser());
    }
    ・
    ・
    public static function getDefault(): self
    {
        return self::$default ?? self::$default = new self();
    }

    /**
     * Registers a MIME type guesser.
     *
     * The last registered guesser has precedence over the other ones.
     */
    public function registerGuesser(MimeTypeGuesserInterface $guesser)
    {
        array_unshift($this->guessers, $guesser);
    }




MimeTypesクラスのインスタンス生成時の動作がわかったので、次はguessMimeTypeメソッドを見ていきます。

$this->guessers に登録されている2つのGuesserクラスのいずれか動作サポートがされている方でMIMEタイプを推測するようですね。
なお、registerGuesserメソッドではarray_unshiftを使って $this->guessers に要素を追加しているので、 FileinfoMimeTypeGuesserFileBinaryMimeTypeGuesser の順に動作サポート状況を判定するようです。

getExtensionsメソッドは引数のMIMEタイプを元に配列を返すことがすでに分かっていますが、自身のクラスの $map という配列の値を返すようです。
staticプロパティの $map はキーがMIMEタイプ、値が拡張子の連想配列となっています。
拡張子「mov」のMIMEタイプは video/quicktime ですので、getExtensionsメソッドでは $map['video/quicktime'] の値である ['qt', 'mov', 'moov', 'qtvr'] が返却されます。
FileクラスのguessExtensionメソッドはMimeTypesクラスのgetExtensionsメソッドで取得した配列の0番目を返却していました。つまり、文字列「 qt」ですね。

symfony/mime/blob/v4.3.8/MimeTypes.php [GitHub]

<?php
    ・
    ・
    /**
     * {@inheritdoc}
     */
    public function getExtensions(string $mimeType): array
    {
        if ($this->extensions) {
            $extensions = $this->extensions[$mimeType] ?? $this->extensions[$lcMimeType = strtolower($mimeType)] ?? null;
        }

        return $extensions ?? self::$map[$mimeType] ?? self::$map[$lcMimeType ?? strtolower($mimeType)] ?? [];
    }
    ・
    ・
    /**
     * {@inheritdoc}
     */
    public function isGuesserSupported(): bool
    {
        foreach ($this->guessers as $guesser) {
            if ($guesser->isGuesserSupported()) {
                return true;
            }
        }

        return false;
    }

    /**
     * {@inheritdoc}
     *
     * The file is passed to each registered MIME type guesser in reverse order
     * of their registration (last registered is queried first). Once a guesser
     * returns a value that is not null, this method terminates and returns the
     * value.
     */
    public function guessMimeType(string $path): ?string
    {
        foreach ($this->guessers as $guesser) {
            if (!$guesser->isGuesserSupported()) {
                continue;
            }

            if (null !== $mimeType = $guesser->guessMimeType($path)) {
                return $mimeType;
            }
        }

        if (!$this->isGuesserSupported()) {
            throw new LogicException('Unable to guess the MIME type as no guessers are available (have you enable the php_fileinfo extension?).');
        }

        return null;
    }
    ・
    ・
    /**
     * A map of MIME types and their default extensions.
     *
     * Updated from upstream on 2019-01-16
     *
     * @see Resources/bin/update_mime_types.php
     */
    private static $map = [
        'application/acrobat' => ['pdf'],
    ・
    ・
        'video/quicktime' => ['qt', 'mov', 'moov', 'qtvr'],
    ・
    ・
    ];




拡張子「mov」がvalidateMimesメソッドで「qt」として判定される原因は、MIMEタイプが「qt」のファイルと共通で「video/quicktime」だからということがわかりました。
これを踏まえて、若干甘めのバリデーションになりそうですが $uploadableFileTypes の定義を以下のように変更することでうまくいきそうです。

変更前

'mov' => 'video/quicktime',

変更後

'qt' => 'video/quicktime',




拡張子「wmv」の場合

拡張子「wmv」のファイルはなぜ「拡張子:asf / MIMEタイプ:video/x-ms-asf」として判定されてしまうのでしょうか。
拡張子「mov」の調査でかなり核心に迫っているのですが、MimeTypesクラスのコンストラクタの以下の処理にヒントがありそうですね。それでは、それぞれのGuesserクラスを見ていきましょう。

symfony/mime/blob/v4.3.8/MimeTypes.php [GitHub]

<?php
    ・
    ・
        $this->registerGuesser(new FileBinaryMimeTypeGuesser());
        $this->registerGuesser(new FileinfoMimeTypeGuesser());




FileBinaryMimeTypeGuesserクラスのコンストラクタの引数「 $cmd = 'file -b --mime -- %s 2>/dev/null' 」を見るといきなり答えが書いてあるんですが、guessMimeTypeメソッドでは引数で受け取ったファイルのパスを $this->cmd に渡して実行しています。

symfony/mime/blob/v4.3.8/FileBinaryMimeTypeGuesser.php [GitHub]

<?php
    ・
    ・
class FileBinaryMimeTypeGuesser implements MimeTypeGuesserInterface
{       
    private $cmd;
            
    /**
     * The $cmd pattern must contain a "%s" string that will be replaced
     * with the file name to guess.
     *
     * The command output must start with the MIME type of the file.
     *
     * @param string $cmd The command to run to get the MIME type of a file
     */     
    public function __construct(string $cmd = 'file -b --mime -- %s 2>/dev/null')
    {   
        $this->cmd = $cmd;
    }

    /**
     * {@inheritdoc}
     */
    public function isGuesserSupported(): bool
    {
        static $supported = null;

        if (null !== $supported) {
            return $supported;
        }

        if ('\\' === \DIRECTORY_SEPARATOR || !\function_exists('passthru') || !\function_exists('escapeshellarg')) {
            return $supported = false;
        }

        ob_start();
        passthru('command -v file', $exitStatus);
        $binPath = trim(ob_get_clean());

        return $supported = 0 === $exitStatus && '' !== $binPath;
    }

    /**
     * {@inheritdoc}
     */
    public function guessMimeType(string $path): ?string
    {
        if (!is_file($path) || !is_readable($path)) {
            throw new InvalidArgumentException(sprintf('The "%s" file does not exist or is not readable.', $path));
        }

        if (!$this->isGuesserSupported()) {
            throw new LogicException(sprintf('The "%s" guesser is not supported.', __CLASS__));
        }

        ob_start();

        // need to use --mime instead of -i. see #6641
        passthru(sprintf($this->cmd, escapeshellarg((0 === strpos($path, '-') ? './' : '').$path)), $return);
        if ($return > 0) {
            ob_end_clean();

            return null;
        }

        $type = trim(ob_get_clean());

        if (!preg_match('#^([a-z0-9\-]+/[a-z0-9\-\+\.]+)#i', $type, $match)) {
            // it's not a type, but an error message
            return null;
        }

        return $match[1];
    }
}




つまり、こういうことのようです。

$ file -b --mime -- /PATH_TO/sample.wmv 2>/dev/null
video/x-ms-asf; charset=binary




もう一つのGuesserクラスであるFileinfoMimeTypeGuesserクラスを見てみると、こちらはPHPのfinfoを利用してMIMEタイプを返却するようです。

symfony/mime/blob/v4.3.8/FileinfoMimeTypeGuesser.php [GitHub]

<?php
    ・
    ・
class FileinfoMimeTypeGuesser implements MimeTypeGuesserInterface
{
    private $magicFile;

    /**
     * @param string $magicFile A magic file to use with the finfo instance
     *
     * @see http://www.php.net/manual/en/function.finfo-open.php
     */
    public function __construct(string $magicFile = null)
    {
        $this->magicFile = $magicFile;
    }

    /**
     * {@inheritdoc}
     */
    public function isGuesserSupported(): bool
    {
        return \function_exists('finfo_open');
    }


    /**
     * {@inheritdoc}
     */
    public function guessMimeType(string $path): ?string
    {
        if (!is_file($path) || !is_readable($path)) {
            throw new InvalidArgumentException(sprintf('The "%s" file does not exist or is not readable.', $path));
        }

        if (!$this->isGuesserSupported()) {
            throw new LogicException(sprintf('The "%s" guesser is not supported.', __CLASS__));
        }

        if (false === $finfo = new \finfo(FILEINFO_MIME_TYPE, $this->magicFile)) {
            return null;
        }

        return $finfo->file($path);
    }
}




つまり、こういうことですね。

$ php -r 'echo (new \finfo(FILEINFO_MIME_TYPE))->file("/PATH_TO/sample.wmv") . PHP_EOL;'
video/x-ms-asf




拡張子「wmv」のMIMEタイプはfileコマンドとfinfoクラスのいずれの方法でも「video/x-ms-asf」と判定されることがわかりました。
なぜこのようなことが起こるのかというと、ファイルの先頭位置に格納されているマジックナンバーと呼ばれるファイル形式を特定するための情報が「asf」と「wmv」のどちらも同じ値のためです。(「wma」も同じ)

hexdumpコマンドでファイルの先頭を確認するとすべて一致しています。

$ hexdump /PATH_TO/sample.asf | head -n 1
0000000 30 26 b2 75 8e 66 cf 11 a6 d9 00 aa 00 62 ce 6c

$ hexdump /PATH_TO/sample.wmv | head -n 1
0000000 30 26 b2 75 8e 66 cf 11 a6 d9 00 aa 00 62 ce 6c

$ hexdump /PATH_TO/sample.wma | head -n 1
0000000 30 26 b2 75 8e 66 cf 11 a6 d9 00 aa 00 62 ce 6c

このあたりについてはマイクロソフトの以下のページで言及されています。

結論として、拡張子「wmv」のファイルを「拡張子:wmv / MIMEタイプ:video/x-ms-wmv」として厳密に判定することは難しそうです。
拡張子「mov」の場合と同様に甘い判定方法となるのは否めませんが、 $uploadableFileTypes の定義を以下のように変更することで対応しました。

変更前

'wmv' => 'video/x-ms-wmv',

変更後

'asf' => 'video/x-ms-asf',

webフォームのバリデーションを行う場合、クライアント側では補助的に行い、サーバー側でしっかりやるのが基本かと思いますが、今回のようにサーバー側で対応が難しい場合もあることがわかり非常に勉強になりました。

おまけ1

長々とソースコードを読みましたが、fileコマンドとfinfoクラスを使ってMIMEタイプを判定することについては、FileクラスのgetMimeTypeメソッドに答えが書いてあったんですよね。。。

    /**
     * Returns the mime type of the file.
     *
     * The mime type is guessed using a MimeTypeGuesserInterface instance,
     * which uses finfo_file() then the "file" system binary,
     * depending on which of those are available.
     *
     * @return string|null The guessed mime type (e.g. "application/pdf")
     *
     * @see MimeTypes
     */

おまけ2

PHPにはmime_content_typeというその名もずばりな関数があるので、これを実行してみると。。。

$ php -r 'echo mime_content_type("/PATH_TO/sample.asf") . PHP_EOL;'
video/x-ms-asf

$ php -r 'echo mime_content_type("/PATH_TO/sample.wmv") . PHP_EOL;'
video/x-ms-asf

$ php -r 'echo mime_content_type("/PATH_TO/sample.wma") . PHP_EOL;'
video/x-ms-asf

はい、結果変わらず。
これについてPHPのソースコードを読んでみましたが「C言語はわからん」という結論に。(ここでファイルのマジックナンバーを読み込んでいるのでは?という箇所は見つけました)

まとめ

今回の「特定の拡張子のファイルのみをアップロードできるようにしてほしい」という要望については、mimesとmimetypesのバリデーションルールの存在を知っていたので比較的簡単に実装できる想定でした。
しかし、蓋を開けてみると想定とは異なる動きが発生し、思っていた以上に対応に時間がかかってしまいました。

良かった点としては、Laravel(というかsymfony)のソースコードをじっくりと読んだことで、ファイルに関するバリデーションの理解がかなり深まったことです。

フレームワークのソースコードは非常に難しいものと思われている方もいらっしゃるかもしれませんが、今回読んだ箇所のように非常に読みやすい内容になっている場合も多いので、積極的に読んでみてコードを書く際の参考にされてみてはいかがでしょうか。