知っている人にとっては何を今更な内容でしょうけれども。
なんてことないけれどハマるときはハマるかもなぁということで記録を残します。

要約

  • メールに記載したリンクを踏ませて何か処理を行うような場合に環境によっては予期しない結果になるかもしれない。
    • Illuminate\Routing\Router#get() は GETメソッドだけでなくHEADメソッドも受け付けるようなルーティングを定義する。
    • 一部のメールクライアントは、HEADリクエストとGETリクエストを続けて投げるような動きをする。
  • GETリクエストだけを受け付けるように明示することが必要な場合がある。

背景

正常な手続きを踏んでいるはずなのに仕様通りの画面遷移にならないとの不具合報告があがってきた。

本来期待される動き

メールの本文中にURLが記載されていて、そのURLにアクセスするとリソースのフラグを立ててチェック完了画面を表示する。
再度同じURLを踏んだ場合(フラグが立っている場合)はチェック済み画面を表示させる。

今回相手にした事象

初めてメールのURLを踏んだにもかかわらず、チェック完了画面ではなくチェック済み画面が表示される。
事象の発生した環境では再現性あり。
報告を受けて試してみるも期待通りの動作をして手元では再現せず。

既存の実装

ざっくり要点だけ抽出すると、こんな処理を書いていた。

public function check($resource_id)
{
    $resource = Resouce::find($resouce_id);

    if ($resouce->flag == 1) {
       // チェック済み画面
       return redirect(route('error_page'))->with(['message' => 
'既にチェック済みです。']);
    }

    $resource->flag = 1;
    $resource->save();

    // チェック完了画面
    return view('check_completed', with(['message' => 'チェックできました。']))
}

このcheckメソッドが、下記のようなルーティングによりディスパッチされる。

Route::get('/foo/{resource_id}', [App\Http\Controllers\HogeController::class], 
'check');

調査

何はなくともまずはログを漁る

アクセスログからリクエストを追ってみると、問題の起きていないリクエストは単にGETが飛んでいるだけだが、問題の起きているリクエストはことごとくGETの前にHEADが飛んでいることが確認できた。
1度のつもりが2度アクセスが行われていると仮定すると合点がいきそう。

GETしか許可してないつもりだが?

でも、GETメソッドでアクセスするアクションなのにと思ったが、HEADメソッドの使われ方を考えるとHEADが通るというのは自然といえば自然。

HEADメソッド?いえ、知らない子ですね

GETやPOSTはよく使うけれど、HEADメソッドについて全く知らないとか、そういえばいたかもねくらいの認識の人もいるかと思うので簡単に触れておく。

RFC9110 HTTP Semantics HEAD

The HEAD method is identical to GET except that the server MUST NOT send content in the response. HEAD is used to obtain metadata about the selected representation without transferring its representation data, often for the sake of testing hypertext links or finding recent modifications.

(直訳)
HEADメソッドは、サーバが応答でコンテンツを送信してはいけない点を除いて、GETと同じ。HEADは、表現データを転送せずに、選択した表現に関するメタデータを取得するために使用される。大抵、ハイパーテキストリンクのテストや最近の変更を見つけるために用いられる。

要は、レスポンスボディは無しでレスポンスヘッダだけで応答するという以外はGETと変わらないということ。
ファイルをダウンロードすることなくファイルサイズを確認したりといったことにも使ったりする。

Laravelではどうなっているの?

実際、Illuminate/Routing/Router#get() を使うとGETとHEADでのルーティングを作成する。(Illuminate/Support/Facades/Route::get()はこれを呼んでるっぽい。)
(余談だけれど、ChatGPT 4にRoute::get()がどのHTTPメソッドを許可するルーティングなのかを聞いてみたら「GETのみ」と答えが返ってきた。人類の作成した大抵の情報がそういうことを書いているということだろうか?)

php artisan route:listなどとしてルーティングの定義を表示させるとGET|HEADとなっていることからも確認できる。 

(疑似的に)再現を試みる

$ curl -I http://localhost:8080/foo/123 などとしてHEADリクエストでのアクセスを試したのちDBの内容をみると、フラグが立つことが確認できた。
HEADメソッドでのアクセスも当該アクションがディスパッチされて処理されているよう。
GETとほとんど変わらないからね。しょうがないね。

残る謎: なんでHEADリクエストがくるの?

あとはそもそもなぜGETの前にHEADがとんでいるかだが、事象の発生した環境ではメールクライアントとしてOutlookを利用しているらしく、それが関係していそうな気はする。(憶測)

仮説

ここまで調べた内容などから、Outlookでメールを開いて表示されたリンクの上にマウスカーソルをホバーさせた時点でプリフェッチのつもりか何かわからないけれどHEADが飛んで1回目のリクエストが通り一度処理が完了となった後に、リンクをクリックすることでGETが飛んで2回目のアクセスとなり既に処理済みですよ画面が表示されるというシナリオを考えてみた。

類似事例

調べてみるとそれらしき(というかほぼドンピシャな)事象に言及している投稿があった。
Outlookなどのメールクライアントのsafe linksという機能でHEADリクエストを使っているらしく意図しない結果を招く場合があるとのこと。

解決方法

素朴にリクエストのHTTPメソッドを確認する

下記のようにGETメソッドのアクセスだけ処理するように変更することで解決した。
(ルーティングで解決する方法もあるのかもしれないが、そこまで調べられていない。)

public function check(Request $request, $resource_id)
{
    if ($request->method() !== 'GET') {
        abort(400, 'Invalid HTTP method.');
    }
    ...