PHPのgetRealPathでファイルパスの代わりにプロジェクトルートが取れてしまった話

スズキです。 Webアプリの画面からファイル添付してサーバに送ることってありますよね。今回は、サーバ側のPHPプログラムでリクエストから添付ファイルを取り出そうとしたら、思ってたのと全然違う挙動になってしまった話です。

やろうとしたこと

やろうとしたことは超シンプルです。
  • HTTPのmultipart/form-dataで送られてきたファイルをリネームして保存する
  • ファイルが無ければエラーにする

サンプルコード(修正前)

PHP+Symfonyのコードです。例外処理などはごっそり省略。
/**
 * ファイル保存
 */
public function uploadFile(Request $request)
{
    // ①リクエストからファイルを取り出す
    $file = $request->files->get('file');
    
    // ②ファイルが一時的に保存されているパスを取得
    $tmpFilePath = $file->getRealPath();
    
    // ③ファイル存在チェック
    if (!$tmpFilePath) {
        return 'ファイルが見つかりません';
    }
    
    // ④ファイル名をリネーム
    $newFilePath = $this->makeNewPath($tmpFilePath);
    rename($tmpFilePath, $newFilePath);

    // ⑤ファイル保存
    $this->saveFile($tmpFilePath);

    return '成功しました';
}

起きたこと

②でファイルパスを取得したとき、通常なら、 // $tmpFilePath : ‘/tmp/phpucYAwE’ といった一時ファイルのパスが取得できるのですが、PHPのアップロードサイズ上限値(php.iniで設定する値)を超えたファイルを送ってみると、
// $tmpFilePath : '/opt/myWebApp/public'
といったように、なぜかWebアプリのルートディレクトリパスが取得されてしまいました。そして、③のファイル存在チェックでもエラー判定できず、そのまま④でアプリのルートディレクトリがリネームされてアプリが動かなくなる事態に…

対処

②でのファイルパス取得にて「getRealPath」を「getPathname」に変えたところ、アップロードサイズ上限を超えたファイルの場合に、期待通りに空文字が取得できるようになりました。
ファイルサイズが大きくない場合は、「getRealPath」と「getPathname」は同じパスを返してくれたので、問題なさそうです。
// $tmpFilePath : '' ←ファイルサイズ上限オーバーのときは空文字が返ってくる(期待通り)

サンプルコード(修正後)

ディレクトリパスが取得されてしまうと悲惨なので、②のパス取得部分だけでなく、安全のため③のファイル存在チェックの条件も増やしています。
/**
 * ファイル保存
 */
public function uploadFile(Request $request)
{
    // ①リクエストからファイルを取り出す
    $file = $request->files->get('file');
    
    // ②ファイルが一時的に保存されているパスを取得
    $tmpFilePath = $file->getPathname(); // getRealPath ではなく getPathname を使用
    
    // ③ファイル存在チェック
    if (!$tmpFilePath || is_dir($tmpFilePath)) { // パスがディレクトリだった場合もエラーにしておく
        return 'ファイルが見つかりません';
    }
    
    // ④ファイル名をリネーム
    $newFilePath = $this->makeNewPath($tmpFilePath);
    rename($tmpFilePath, $newFilePath);

    // ⑤ファイル保存
    $this->saveFile($tmpFilePath);

    return '成功しました';
}
 

原因推測

「getPathname」と「getRealPath」はどちらも似た説明になっていますが、「getRealPath」は絶対パスを取得するようです。 「getRealPath」が絶対パスに変換する処理の中で、空文字のパスがプロジェクトルートと解釈されたのかな…と推測しています。 リクエストに添付ファイル自体が無ければ、①の $file = $request->files->get('file'); のところでnullが取得されて特に被害は発生しないのですが、サイズ上限オーバーの場合は中途半端にファイルが存在する感じになっていそうです。 今回は事例紹介まで。

SymfonyとVue.jsを組み合わせるときはSymfony Formを使うのは諦めた方が良さそう

初めまして。イメージ・マジックのスズキです。
いきなりですが、本稿は『SymfonyとVue.jsを組み合わせるときは「Symfony Forms」の機能を使うのは諦めた方がいいかもしれない』ということを伝えたい、ただそれだけの記事です。『エンジニアたる者、どんなときでもまずは結論から話せ』と、物心ついた頃(嘘)から聞かされてきましたしね。
Symfonyで一般的なデータの入力や保存を行う画面をつくるなら、「Symfony Forms」という機能を利用するのが基本のようです。
公式ドキュメントでも、基本的な実装方法として紹介されています。

Symfony Formsを使うことで、コントローラー側でフォームと画面項目の定義をしておいて、画面側(テンプレート側)では自力でタグを書いたりせずに表示することができます。
試しに、テキスト項目が2つあるだけの画面を作る場合、こういう感じになるでしょうか。
// エンティティ
namespace App\Entity;

class Master
{
    private string $name = '';
    private string $description = '';

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function setDescription(string $description): self
    {
        $this->description = $description;
        return $this;
    }
}
// フォーム
namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class MasterEditType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('desctiption', TextType::class)
        ;
    }
}
// コントローラー
namespace App\Controller;

use App\Entity\Master;
use App\Form\Type\MasterEditType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class MasterEditController extends AbstractController
    public function edit(Request $request) : Response
    {
        $master = new Master();
        // ...
        
        $form = $this->createForm(MasterEditType::class, $master);

        return $this->render('master/edit.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}
<!-- 画面テンプレート(Symfony Form使用) -->
<div>
    <div>
        <label>名称</label>
        {{ form_widget(form.name) }}
    </div>
    <div>
        <label>説明</label>
        {{ form_widget(form.description) }}
    </div>
</div>
画面のテンプレートには <input type="text"> といったごく普通のHTMLタグが登場しません。Symfony formsがタグを生成してくれるのですね。

ただ、リッチなフロント画面をVue.jsでごりごり書きたいときは、<input type="text" v-model="master.name">などとVue.jsのディレクティブを入れることになるのですが、Symfony Formsを使っているとそれがしにくくなります。
そんなわけで、Vue.jsでいろいろやりたいならば、Symfonyは使ったとしてもSymfony Formsの機能は使わない方が良さそう、といった印象です。

Symfony Formsを使わずに書くなら、サーバ側ではSymfony Formの代わりに単純なオブジェクトを作ってリターンし、画面側ではVue.jsのお作法でそれを表示するような作りが良さそうです。
先ほどのコードを、Symfony Formsを使わずに(Vue.jsを使えるように)書き直すと、こんな感じでしょうか。
// 変更後のコントローラー(Symfony Formを使わない)
namespace App\Controller;

use App\Entity\Master;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class MasterEditController extends AbstractController
    public function edit(Request $request) : Response
    {
        $master = new Master();
        // ...

        return $this->render('master/edit.html.twig', [
            'master' => [
                'name' => $master->getName(),
                'description' => $master->getDescription(),
            ],
        ]);
    }
}
// 変更後の画面テンプレート(Vue.js使用)
<div>
    <div>
        <label>名称</label>
        <input type="text" v-model="mst.name">
    </div>
    <div>
        <label>説明</label>
        <input type="text" v-model="mst.description">
    </div>
</div>
<!-- Vue.jsのスクリプト部分 -->
<script>
export default {
  props: {
    master: {},
  },
  setup(props) {
    const mst = ref(props.master);
    return {
      mst,
    };
  },
}
</script>
ということで今回は、SymfonyとVue.jsを組み合わせるときのお話でした。Symfony単体やVue.js単体での使い方は情報があっても、組み合わせ方の情報はなかなか無いので書いてみました。
Web界隈はフレームワークやライブラリがたくさんあって、組み合わせるときは試行錯誤が要りますね。