0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

URLにIDを載せたくない → POSTで編集画面に飛んでみたら地獄だった話

Last updated at Posted at 2025-07-06

この記事の概要

Laravel で、
セキュリティのために、
編集画面を表示する editアクションに、POSTメソッドでリクエストする

これできないかを検証したので、その記録です。

試したのはかなり前のことなので、矛盾点があるかもしれません。

やりたかったこと

当時作ろうとしていたサービスは、ユーザーが他者の個人情報を管理する、名簿管理ができるようなWebアプリでした。
ここでは、ユーザーが学校の先生で、生徒の個人情報を管理したいとします。

更新機能の実装中でした。
先生が生徒の情報を編集し更新できるようにしたいです。

まずは、生徒の一覧画面に、編集ページへ飛べるリンクを設置しました。

/resources/views/students/index.blade.php
<a href="{{ route('students.edit', $student->id) }}">編集</a>

ルーティングも書きます。

/routes/web.php
Route::get('students/{id}/edit', [StudentController::class, 'edit'])->name('students.edit')

続いてコントローラで、Studentモデルから情報を取ってきて、編集画面に渡します。

/app/Http/Controllers/StudentController.php
public function edit($id): View
{
    $student = Student::find($id);
    return view('students.edit', compact('student'));
}

なお、当時はルートモデルバインディングという機能を知らなかったため、モデルインスタンスではなくidを使ってやりとりしています。
今はルートモデルバインディングを便利に活用しています。
ルートモデルバインディングについてはこちらの記事をどうぞ。
https://ensei1375.com/laravel-route-modelbinding/

ルートモデルバインディングを使っていないという点を除けば、おおよそ一般的なやり方ではないかと思います。

この実装に潜むリスク

しかし、これには一つ懸念点があります。
URLにidが乗ってしまうというセキュリティリスクです。

例えば商品IDなどであれば、URLに乗ってしまっても特に問題はありません。他のidを叩かれても、他の商品のページが表示されるだけだからです。

しかし今回は、生徒の情報が担任の先生以外からは見えてはいけません。個人情報だからです。

そこでできそうな対策は3つ。

  • idを連番ではなく、予測不能なもの、すなわちUUIDにする
  • 管理用のidはインクリメントのままにし、表示用の生徒IDを別で作る
  • editへの遷移をPOSTリクエストで行うことで、見えなくする

上の二つはDBに変更を加えなくてはなりません。ちょっと怖いしめんどくさいので、できることならやりたくありませんでした。

POSTにするだけなら実装は簡単そうです。とりあえずそっちでやってみようとしました。

これが地獄の始まりでした。

やったこと

まずは、生徒一覧ページにあった編集ページへのリンクを、フォームに書き換えました。

/resources/views/students/index.blade.php
<form method="POST" action="{{ route('students.edit') }}">
	@csrf
	<input type="hidden" id="student_id" name="student_id" value="{{ $student->id }}">
	<input type="submit" value="編集">
</form>

input typehiddenにして、このボタンを押すと隠しフォームとして生徒のidが送信されるようにしました。

当然ルーティングもGETからPOSTに書き換えます。
idは上記のとおり隠しフォームでやりとりするため、URLに乗らないように、消します。

/routes/web.php
Route::post('students/edit', [StudentController::class, 'edit'])->name('students.edit')

コントローラでは、$requestから生徒のidを受け取るようにしました。

/app/Http/Controllers/StudentController.php

    public function edit(Request $request): View
    {
		$student_id = $request->student_id;
        $student = Student::find($student_id);
        return view('students.edit', compact('student')));
    }

ここまでで、編集画面を表示できました。editアクションの実装が完了です。
編集画面にはこんな感じのフォームを用意しました。
先ほどの一覧画面と同じく、生徒のidを受け渡す用の隠しフォームも設置しています。

/resources/views/students/edit.blade.php
<form method="POST" action="{{ route('students.update') }}">
    @csrf
    @method('PATCH')

    <input type="hidden" id="student_id" name="student_id" value="{{ $student->id }}">

    <!-- (ここにそれぞれの項目のinputタグ) -->
    
    <button>更新</button>
</form>

更新処理を担うupdateアクションは以下のように実装しました。

/app/Http/Controllers/StudentController.php


    public function update(StudentUpdateRequest $request)
    {
        $student_id = $request->student_id;
        $student = Student::find($student_id);
        $student->fill($request->validated());
        $student->save();

        return to_route('students.index')->with('status', 'student-updated');
    }

ルーティングも設定しています。

/routes/web.php
Route::post('students/edit', [StudentController::class, 'edit'])->name('students.edit');
Route::patch('students/update', [StudentController::class, 'update'])->name('students.update');

そして、実際にeditページに必要事項を入力して、送信ボタンを押してみました。

結果

すると以下のようなエラーが発生します。
この後何度も見ることになる文言です。

The GET method is not supported for route students/edit. Supported methods:POST

students/editにはGETメソッドはサポートされていません。POSTメソッドならサポートされています。
というような意味ですね。

そう設定したので当然です。
だから、POSTで送信しています。
GETで送信した覚えはありません。

隠しフォームの中に@method(’post’)と明記してみてもダメでした。

試したこと

このエラーが出る場合の対処法として、以下のような情報が得られました。

GETメソッドを定義しているか?GETがないとPOSTができない。

同じエラーに悩み、解決した人がいました。

以下のように書かれています。

Route::getpostの前に追加しただけです。

getアップロード画面を取得(upload)するために使い、postはアップロード画面で「アップロードする」を押すことで指定の送信先ルート(uploaded へアップするデータを送信するために使います。

どうやら、 「編集画面表示と更新処理はセットで実装しましょう」 という話のようです。それであれば満たしているはずです。

②ルートのキャッシュをクリアしたか?

試しましたが、ダメでした。

③バリデーションに引っかかっていないか?バリデーションを外して、通るか確認しよう

ここで有力な説が登場しました。

こちらの回答に助けられました。

EntryRequestという自作のRequestクラスにバリデーションを記述して、タイプヒントEntryRequest $requestすることで自動的にバリデーションを実施してくれます。

今回は、どうも、バリデーションに失敗して、前の画面entry/storeへリダイレクト(get)していたので、ルーティングが見つからずエラーになっていた様です。

EntryRequestではなく、Requestに変えると、バリデーションを実施しないので前の画面に戻らなくなりました。

バリデーションに引っかかると、直前の画面に自動でGETリクエストが送られリダイレクトするため、あのようなエラーが出た。 という話のようです。

それではバリデーション周りの挙動を確認してみましょう。

当時このようなFromRequestクラスを実装していました。

/app/Http/Requests/StudentUpdateRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Models\Student;


class StudentUpdateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'last_name' => ['required', 'string', 'max:255'],
            'first_name' => ['required', 'string', 'max:255'],
            'email' => ['nullable', 'confirmed', 'string', 'lowercase', 'email', 'max:255'],
            'postcode' => ['nullable', 'string', 'max:255'],
            'address' => ['nullable', 'string', 'max:255'],
            'phone_number' => ['nullable', 'string', 'max:255'],
            'note' => ['nullable', 'string'],
        ];
    }
}

バリデーションに引っかかっていた要因

あとからわかったことですが、このときemailがユニーク制約に引っかかっていました。
他に同じメールアドレスのユーザはいないはずなのに……と思い調べてみると、以下の記事にわかりやすく解説がありました。

メールアドレスは変更せずに他の項目だけ更新しようとした場合に、問題が起こるようです。

単純なユニーク制約だと、更新前の自分自身を見つけてバリデーションエラーとなります

Laravelくん、融通効かないな。

Readoubleを参考に、修正してみます。

/app/Http/Requests/StudentUpdateRequest.php
    public function rules(): array
    {
        $student_id = $this->input('student_id');
        
        return [
            'last_name' => ['required', 'string', 'max:255'],
            'first_name' => ['required', 'string', 'max:255'],
            'email' => [
                'nullable',
                'confirmed',
                'string',
                'lowercase',
                'email',
                'max:255',
                Rule::unique(Student::class)->ignore($student_id),
    ],
            'postcode' => ['nullable', 'string', 'max:255'],
            'address' => ['nullable', 'string', 'max:255'],
            'phone_number' => ['nullable', 'string', 'max:255'],
            'note' => ['nullable', 'string'],
        ];
    }
}

Readoubleではコントローラ内でバリデーションしていましたが、私はFormRequestクラス内でのバリデーションなので、少し書き方が変わっています。
input()を使って、隠しフォームから送信された生徒のidを取得し、そのidを無視するように設定しています。こうすれば自分自身のメールアドレスに引っかかることはなくなります。
これで、FormRequestが原因のバリデーションエラーはなくなるはずです。

諦めモードへ

しかしこのときは、どうしてバリデーションに引っかかっているのか気づくことはできませんでした。
ずっと、前述の The GET method is not supported for route students/edit. Supported methods:POST というエラーしか出ず、それ以上詳細がわからなかったからです。
この究明を前に、ここで諦めモードに入りました。

意図せずGETリクエストが送信されている原因は、バリデーションに引っかかっているからだったわけです。
つまりそれは、ユーザが入力値を間違えてバリデーションエラーが起こるたびに、毎回リダイレクトされてGETリクエストになる ということです。その自動リダイレクトを止められない限り、実質この実装は不可能なのです。

リダイレクトはPOSTでできないのか?

しかし逆に言えば、バリデーションエラー時にPOSTでリダイレクトができれば、この問題は解消できるということになります。
調べてみると、「バリデーションエラー時にPOSTでリダイレクトしたい」という質問に対して、こういった回答がありました。

postでリダイレクトはできません。
リダイレクトさせた場合必ずGETリクエストになります。

AIにも聞いてみました。
fails()という、エラー時の動きを拾い上げるメソッドがあるのでそれ使ったやり方も試しましたが、同じく The GET method is not supported for route students/edit. Supported methods:POST が出るだけでした。

/app/Http/Controllers/StudentController.php
    public function update(StudentUpdateRequest $request): View
    {
        if ($request->fails()) {
            return back()->withInput();
        }

        $student_id = $request->id;
        $student = Student::find($student_id);
        $student->fill($request->validated());
        $student->save();

        return to_route('students.index')->with('status', 'student-updated');
    }

これで解決しなかった要因としては、以下のサイトに説明がありました。

リクエスト時にはまずValidatesWhenResolvedTrait::validateResolved()が呼ばれるのだが、その中でif ($instance->fails()) { $this->failedValidation($instance); }までべったり書かれている。
そしてFormRequest::failedValidation()はその中でリダイレクトしている。
ここまでコントローラより先に動くので、コントローラからは手を出す手段がない。

つまり、 コントローラに辿り着く前にFormRequestでバリデーションに引っかかってリダイレクトされているので、コントローラに何か書いても意味がないようです。

加えて、そもそもFormRequestにはfailsメソッドはないようです。
FormRequestでのバリデーションエラー時は強制リダイレクトするため、コントローラには辿り着きません。そのため、$request->fails()のように コントローラ内でバリデーションの成否をチェックすることはできません。
コントローラ内でバリデーションの成否を制御したい場合は、FormRequestではなく、Validatorでバリデーションを実行する必要があります。

本当にPOSTでのリダイレクトはできないのでしょうか?
他のFWではどうかなと思い立ち、参考にRailsで調べてみました。

誰しも一度は考えたことがあると思います、POSTやPUTにリダイレクトしたい・・・できればパラメータも引き継いで、と。

しかし、HTTP/1.1 プロトコルの制約があるため、これを実現するのは不可能だと思われます。

どうやら HTTPのプロトコル上で制約があるようです。

じゃあ無理だ!!

結論

mdnを見てもわかるように、表示にはGETが推奨されているので、素直にGETでやった方がよさそうです。

代わりにできるセキュリティ対策としては、

  • 編集対象の生徒を作成した先生ユーザのIDと、ログイン中ユーザとの一致を確認する 権限チェック1を実施
  • 以下のどちらか
    • 生徒のidをUUIDにして、連番による推測をできなくする
    • 生徒のidは管理用としてインクリメントのままにし、表示用のidをUUIDなどで別途作る

このあたりでしょうか。

ひとまず私は権限チェックだけ実施するようにしました。
主キーをUUIDにした方がよいかについては賛否ある2ようでしたので、十分に調べてから実施を検討します。

感想

素直に原則に沿った書き方をしよう!

  1. 簡単な一致確認のメソッドをコントローラやモデルに作成するか、Policyを利用するといった方法があります。

  2. 現時点で調べたUUIDの主なデメリットとしては、連番でないため管理がしにくいことや、パフォーマンスが落ちることなどが挙げられるようです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?