TL;DR(長いので最初に結論)
-
この記事が書かれた2021年当時は、destroyの
redirect_to
にはstatus: :see_other
を付けないと問題が起きる可能性があった - turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
- とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常に
status: :see_other
を付けた方が安心(つまり、status: :see_other
は付けるべき) - Rails 7.1以降ならscaffoldで生成したコードに
status: :see_other
が付いている
はじめに
以前、筆者はこんな記事を書きました。
この記事の中に「status: :see_other がないと非常に危険!!」という項目があります。この記事を書いた当時(2021年)はたしかに危険性が高かったのですが、現在ではそのリスクはかなり低くなっています。
しかし、なぜリスクが低くなったのかについては、様々な技術要素が絡み合っているため、ひとことで説明するのが非常に難しいです。そこでこの記事では「Railsのdestroyアクションにstatus: :see_other
を付けるべきか否か?」を判断するために各技術要素について順に説明し、最後になぜ現在ではリスクが低くなったのかを説明します。
結論に至るまでちょっと難しい話題が続きますが、Railsアプリケーション開発者はしっかり理解しておきたい内容なので、じっくり読んで理解を深めていってください。
HTTPのリダイレクトの仕様を理解する
302だけではないリダイレクト用のステータスコード
「リダイレクト用のステータスコードって302でしょ?」と思いこんでいる方は要注意。実は302以外にも様々なステータスコードが存在します(下記MDNを参照)。
この中でも今回の話の中心になるのが、以下の2種類です。
- 302 Found
- 303 See Other
302 Found
302 Foundはリダイレクト時のメソッドの扱いに要注意です。
GETでリクエストが送られてきた場合はGETでリダイレクトします。しかし、それ以外のメソッド(POST、PUT、PATCH、DELETE)でリクエストされた場合は、リダイレクト時に使われるメソッドの仕様があいまいです。
メソッドの扱い
仕様書ではメソッドの変更を意図していませんが、実際はメソッドを変更するユーザーエージェントが存在します。
慣習的にPOSTでリクエストが送信された場合のみ、GETでリダイレクトするユーザーエージェント(ブラウザ)が多いようです。
これは言い換えると、PUT/PATCH/DELETEでリクエストが送られてきた場合は、GETではなく、PUT/PATCH/DELETEでリダイレクトする可能性が高いことを意味します。
303 See Other
一方、303 See Otherはどのメソッドでリクエストが送られてきても必ずGETでリダイレクトします。
メソッドの扱い
GET メソッドは変更しません。 他のメソッドは GET に変更します主な使用例
ページの再読み込みによって操作が再度実施されることを防ぐために、PUT や POST の後のリダイレクトで使用します。
主な仕様例で書かれているように、Railsアプリケーションなどでデータを更新し、それから他の画面へリダイレクトする場合は、まさにこの303 See Otherが適任です。
HTMLフォームの仕様を理解する
ブラウザでHTMLフォーム(つまりformタグ)を使う場合、method属性に指定できるのはGETかPOSTだけです。
RailsがPUT/PATCH/DELETEでリクエストする場合の仕組み
そのため、PUT/PATCH/DELETEのリクエストを送信したい場合、Railsでは隠し項目として_method
を用意し、ここに指定したアクションをフォームのメソッドと見なすようになっています。
<!-- ブラウザからはPOSTとしてフォームを送信する -->
<form action="/blogs/123" method="post">
<!-- Railsは_methodで指定されているPATCHをこのフォームのメソッドと見なす -->
<input type="hidden" name="_method" value="patch">
Railsのbutton_toとlink_toの違いを理解する
button_toはformタグを出力する
button_to
を使うとHTMLにはformタグが出力されます。
<%= button_to 'Delete', [task.project, task], method: :delete %>
<form action="/projects/3/tasks/6" method="post">
<input type="hidden" name="_method" value="delete">
<button type="submit">Delete</button>
</form>
erbのコード例では method: :delete
を指定していますが、HTML上ではformタグのmethodはPOSTが、隠し項目の_method
にはDELETEが指定されている点に注目してください。
link_toはaタグを出力し、Turboがリクエストを送信する
一方、link_to
を使った場合はaタグが出力されます。
<%= link_to 'Delete', [task.project, task], data: { turbo_method: :delete } %>
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
link_to
はaタグなので、通常であればGETでリクエストを送信しますが、data: { turbo_method: :delete }
のようなオプションを付けることでPOST/PUT/PATCH/DELETEのリクエストも送信できます。
なぜaタグなのにGET以外のリクエストが送信できるのかというと、TurboがJavaScriptの力でaタグの送信リクエストを乗っ取ってむりやりPOST/PUT/PATCH/DELETEのリクエストを送信しているからです。
Turboで実装されている data-turbo-method の挙動を理解する
Turboはdata-turbo-method="delete"
のような属性が付いていると、リンククリック時に動的に生成したformオブジェクトのmethod属性にそのままセットします。
// https://github.com/hotwired/turbo/blob/v8.0.11/src/observers/form_link_click_observer.js#L50-L51
const method = link.getAttribute("data-turbo-method")
if (method) form.setAttribute("method", method)
このため、通常のHTMLフォームでは指定できないPUT/PATCH/DELETEがmethod属性に指定されることになります。
turbo-rails gemのバージョンによる挙動の違いを理解する
バージョン1.1.1まで
Turboのデフォルトの実装をそのまま利用します。つまり、
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
というようなリンクがあれば、/projects/6/tasks/12
に対してDELETEでリクエストが送信されます。
バージョン1.3.0以降
バージョン1.3.0以降(バージョン1.2はなぜか存在しない)ではTurboのフォーム送信イベントをフックして、Railsでよく利用される「formのmethodはPOST + _method
でPUT/PATCH/DELETEを指定」という送信形式に変換します。
つまり、
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>
というようなリンクがあれば、/projects/6/tasks/12
に対してPOSTでリクエストが送信されます。(DELETEメソッドであることは_method
で指定する)
status: :see_otherを付けなければいけないのはどんなとき?
さて、ここまでの前提条件が揃ってようやく本記事の本題に入れます。
以前書いたこちらの記事では「status: :see_other
がないと非常に危険!!」と書きましたが、これは以下の条件を満たしていたためです。
-
link_to
メソッドでdata: { turbo_method: :delete }
を指定している → TurboがDELETEでリクエストを送信する - turbo-railsのバージョンが1.1.1以下 → Turboの実装がそのまま使われる
- controllerで
redirect_to
にstatus: :see_other
を指定していない → ステータスコードとして302が使われる。302だとGETではなくDELETEでリダイレクト先にリクエストを送信する
このような挙動になるため、リダイレクト先のURL(たとえば /projects/3
)がDELETEリクエストを受け付ける場合、意図せずid=3のProjectが削除される危険性がありました。
よって、この問題を回避するためにredirect_to
にstatus: :see_other
を付ける必要があります。
redirect_to @project, status: :see_other
status: :see_other
を付ければステータスコードは303になります。303であれば必ずGETでリダイレクトするため、本来の意図通りid=3のProjectページを表示してくれます。
こういうケースでは status: :see_other を付けなくても大丈夫(かもしれない)
以下のいずれかの条件に合致すればredirect_to
にstatus: :see_other
を付けなくても問題ないかもしれません。
-
button_to
メソッドを使っている → PUT/PATCH/DELETEであってもブラウザはPOSTでフォームを送信する。POSTであれば、ステータスコードが302であってもGETでリダイレクトされる - turbo-railsのバージョンが1.3.0以上 →
data-turbo-method="delete"
が指定されていても、内部的にPOSTでフォームを送信するように切り替えてくれる
特に、turbo-railsのバージョンが1.3.0以上だった場合の恩恵は非常に大きいですね。turbo-rails 1.3.0のリリースは2022年9月なので、これ以降にrails newした場合はstatus: :see_other
を付けなくても大丈夫な可能性が高くなります。
【重要】でも、本当に万全を期すなら……
ただし、redirect_to
にstatus: :see_other
を付けない、というのは言い換えると、「リクエストが必ずPOSTで送られてくる前提でアプリケーションが作られている」ということです。
Webアプリケーションはその仕組み上、誰でもどこからでも自由にリクエストを送信できます。その気になればDELETEでリクエストを送ることも可能です(例: あえてturbo-railsを使わず、素のTurboがリクエストを送信する場合など)。status: :see_other
がない状態で、もし予期せずDELETEでリクエストが送られてきた場合は302、つまりDELETEでリダイレクトすることになり、大事なリソースが削除されてしまうリスクは残ったままになります。
というわけで、万全を期すならdestroyアクションのredirect_to
は必ずstatus: :see_other
を付ける、とした方が安全です。
def destroy
@task.destroy
# 万が一DELETEでリクエストされた場合に備えてstatus: :see_otherを付けておく
redirect_to @project, status: :see_other
end
また、同じことはupdateアクションにも言えます。なぜならPUT/PATCHでリクエストが送られてきた場合、302でリダイレクトするとPUT/PATCHでリダイレクト先にアクセスしてしまうためです。よって、updateアクションにもstatus: :see_other
を付けるべきです。
def update
if @task.update(task_params)
# 万が一PUT/PATCHでリクエストされた場合に備えてstatus: :see_otherを付けておく
redirect_to @task, status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
Rails 7.1以降ならもう大丈夫?
実はバージョン7.1以降のRailsではscaffoldコマンド(またはrails g controller
コマンド)を実行すると最初からupdateアクションやdestroyアクションにstatus: :see_other
が付くようになっています。
よって、Rails 7.1以降を使っている場合は心配無用です!・・・と言いたいところですが、注意点が2つあります。
注意点1. :see_otherが付くのはコードを自動生成したときだけ
status: :see_other
が付くのはscaffoldコマンドやrails g controller
コマンドでコントローラのコードを生成したときだけです。
Rails 7.0以前に書いた既存のコードや、どこかからコピペしてきたcontrollerのコードについては、自分でstatus: :see_other
を付ける必要があります。
注意点2. jbuilderを使っているとdestroyにしか:see_otherが付かない
scaffoldコマンドやrails g controller
コマンドを使ったコードの自動生成は、jbuilder gemをインストールしている(Gemfile.lockにjbuilderが含まれている)とRails本体ではなく、jbuilder側の自動生成コードが使われます。
実は本記事の執筆時点(2025年2月)の最新バージョンであるjbuilder 2.13.0では、destroyアクションにしかstatus: :see_other
が付きません。そのため、updateアクションについては自分でstatus: :see_other
を付ける必要があります。
この問題についてはすでにupdateアクションにもstatus: :see_other
を付けるプルリクエストが作成されているため、そのうち解決されるかもしれません。
まとめ
というわけで本記事では「Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か?」と題して、技術的な内容を深掘りしてみました。
長々と書いてきましたが、簡単に(雑に?)まとめると、
-
この記事が書かれた2021年当時は、destroyの
redirect_to
にはstatus: :see_other
を付けないと問題が起きる可能性があった - turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
- とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常に
status: :see_other
を付けた方が安心(つまり、status: :see_other
は付けるべき) - Rails 7.1以降ならscaffoldで生成したコードに
status: :see_other
が付いている
ということになります。
Special thanks to: Hotwire.loveのみなさん
このトピックは先日開催されたHotwire.love Meetup vol.41で挙がった話題です。
この日は参加者全員でワイワイガヤガヤとコードやissueを眺めながら「status: :see_other
とは何なのか」を楽しく深掘りしました。参加者のみなさん、どうもありがとうございました!
Hotwire.love はHotwire関連の話題を楽しくおしゃべりするオンラインコミュニティです。初心者経験者を問わず、どなたでもお気軽にご参加ください!😄