この記事の概要
Laravel で、
セキュリティのために、
編集画面を表示する edit
アクションに、POST
メソッドでリクエストする
これできないかを検証したので、その記録です。
試したのはかなり前のことなので、矛盾点があるかもしれません。
やりたかったこと
当時作ろうとしていたサービスは、ユーザーが他者の個人情報を管理する、名簿管理ができるようなWebアプリでした。
ここでは、ユーザーが学校の先生で、生徒の個人情報を管理したいとします。
更新機能の実装中でした。
先生が生徒の情報を編集し更新できるようにしたいです。
まずは、生徒の一覧画面に、編集ページへ飛べるリンクを設置しました。
<a href="{{ route('students.edit', $student->id) }}">編集</a>
ルーティングも書きます。
Route::get('students/{id}/edit', [StudentController::class, 'edit'])->name('students.edit')
続いてコントローラで、Student
モデルから情報を取ってきて、編集画面に渡します。
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
にするだけなら実装は簡単そうです。とりあえずそっちでやってみようとしました。
これが地獄の始まりでした。
やったこと
まずは、生徒一覧ページにあった編集ページへのリンクを、フォームに書き換えました。
<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 type
をhidden
にして、このボタンを押すと隠しフォームとして生徒のid
が送信されるようにしました。
当然ルーティングもGET
からPOST
に書き換えます。
id
は上記のとおり隠しフォームでやりとりするため、URLに乗らないように、消します。
Route::post('students/edit', [StudentController::class, 'edit'])->name('students.edit')
コントローラでは、$request
から生徒のid
を受け取るようにしました。
public function edit(Request $request): View
{
$student_id = $request->student_id;
$student = Student::find($student_id);
return view('students.edit', compact('student')));
}
ここまでで、編集画面を表示できました。edit
アクションの実装が完了です。
編集画面にはこんな感じのフォームを用意しました。
先ほどの一覧画面と同じく、生徒のid
を受け渡す用の隠しフォームも設置しています。
<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
アクションは以下のように実装しました。
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');
}
ルーティングも設定しています。
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::get
をpost
の前に追加しただけです。
get
は アップロード画面を取得(upload
)するために使い、post
はアップロード画面で「アップロードする」を押すことで指定の送信先ルート(uploaded
) へアップするデータを送信するために使います。
どうやら、 「編集画面表示と更新処理はセットで実装しましょう」 という話のようです。それであれば満たしているはずです。
②ルートのキャッシュをクリアしたか?
試しましたが、ダメでした。
③バリデーションに引っかかっていないか?バリデーションを外して、通るか確認しよう
ここで有力な説が登場しました。
こちらの回答に助けられました。
EntryRequest
という自作のRequest
クラスにバリデーションを記述して、タイプヒントEntryRequest $request
することで自動的にバリデーションを実施してくれます。
今回は、どうも、バリデーションに失敗して、前の画面
entry/store
へリダイレクト(get
)していたので、ルーティングが見つからずエラーになっていた様です。
EntryRequest
ではなく、Request
に変えると、バリデーションを実施しないので前の画面に戻らなくなりました。
バリデーションに引っかかると、直前の画面に自動でGET
リクエストが送られリダイレクトするため、あのようなエラーが出た。 という話のようです。
それではバリデーション周りの挙動を確認してみましょう。
当時このようなFromRequest
クラスを実装していました。
<?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を参考に、修正してみます。
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
が出るだけでした。
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ようでしたので、十分に調べてから実施を検討します。
感想
素直に原則に沿った書き方をしよう!