はじめに
Railsの勉強中、SQLインジェクションについての理解が浅く、つい危険なコードを書いてしまうことがありました。
この記事ではサンプルアプリを作って実際にSQLインジェクションを起こし、クエリを確認した結果をまとめます。 初心者が勉強のために書いた記事のため、間違いがあれば指摘していただけると助かります。
結論
Active Recordの検索メソッドwhere
を使う時、条件文字列の中に変数を直接置くのは、SQLインジェクションの危険があるため、やってはいけない。
プレースホルダ を使うなど、自動的にエスケープされる書き方にする。
Project.where("name = '#{params[:name]}'") # NG Project.where("name = ?", params[:name]) # OK
実行環境
サンプルアプリの準備
scaffoldを使い、必要最低限の実験用アプリを作りました。
コード:GitHub - SuzukaHori/Sample-app-for-experimentation
データベースは、memosテーブルにuser・content・draftのカラムがあります。 draftがtrueの場合はStatusに「下書き」、falseの場合は「公開」と表示されています。
データが3件入っており、長男と三男のメモは公開状態(水色)、次男のメモは下書き(ピンク)です。後ほど下書きメモは表示されないようにします。
このアプリに検索フォームを作り、SQLインジェクションを発生させ、発行されるクエリを確認していきます。
検証
1. 初めの状態を確認する
scaffoldした時点での、コントローラーのindex
アクションのコードを抜粋します。
def index @memos = Memo.all end
発行されるクエリは下の通りです。
SELECT "memos".* FROM "memos" /* 訳: テーブルmemosからすべての列を選択する。*/
2. 下書きのメモを見えないようにする
このままだと下書きのメモまで見えてしまうので、draftカラムがfalseのメモだけが表示されるようにします。
コントローラーのindex
アクションを以下のように書き換えます。
@memos = Memo.where(draft: false)
発行されるクエリは下の通りです。
SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 /* 訳: テーブルmemosからdraftカラムがfalseのものを選択する。*/
0はfalseを示すので、下書き状態のメモだけが選択されます。
3. 検索フォームで絞り込めるようにする
今回は検索フォームでSQLインジェクションを発生させるので、index.html.erbにコードを追加し、フォームを作りました。
コード
<%= form_with url: memos_path, method: :get do |form| %> <%= search_field_tag :term, params[:term]%> <%= submit_tag 'Search', name: nil %> <% end %>
このフォームでメモを検索できるようにするため、コントローラーに絞り込みの条件を追加します。(脆弱性があるコードです)
@memos = Memo.where(draft: false).where("content LIKE '%#{params[:term]}%'")
追加したwhere("content LIKE '%#{params[:term]}%'")
の意味を確認しましょう。
LIKEは検索、%
は0文字以上の任意の文字列を示します。
#{}
は式展開で、検索された文字を示すparams[:term]
の値を取り出します。
つまりwhere("content LIKE '%#{params[:term]}%'")
は、「contentが検索文字列を含むメモの絞り込み」を表しています。
検索文字列に「良い」を入れて、発行されるクエリを見てみましょう。
SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 AND (content LIKE '%良い%') /* 訳: テーブルmemosから、draftカラムがfalseでありかつcontentカラムが「良い」を含むものを選択する。*/
AND
は「かつ」という意味なので、「下書きではない、かつ内容に良いを含むもの」を検索します。
実際に作ったフォームで「良い」を検索してみます。
SQLインジェクションを発生させてみる
さて、上で作った検索フォームはSQLインジェクションの脆弱性があります。
検索フォームに') OR 1 = 1 --
を入れて、実際にやってみましょう。
クエリを確認します。
SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 AND (content LIKE '%') OR 1 = 1 --%') /* 訳: テーブルmemosから、draftカラムがfalseでありかつcontentカラムが任意の文字列を含むもの、または`1 = 1`の条件を満たすものを選択する。*/
結論から書くと、OR 1 = 1
がSQLの一部として解釈されることによって「すべてのデータを選択せよ」という意味になっています。
WHERE
以降を分解します。
AND (content LIKE '%')
の部分では、%
は任意の0文字以上を示すため、ここでは何も絞り込みません。
問題はOR 1 = 1
です。OR
は「または」という意味、1 = 1
は常にtrueになりますので、すべてのデータがこの条件を満たします。さらに、--
は行末までに記述された文字列をコメントとするため、不要な記号%')
があることによる構文エラーも出ません。
4. プレースホルダを使って書き直す
このままではいけないので、SQLインジェクション対策をしましょう。
条件文字列の中には変数でなく?
を置き、第二引数で変数を指定します。
@memos = Memo.where(draft: false).where("content LIKE ?", "%#{params[:term]}%")
ここで発行されるクエリは以下のとおりです。
SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 AND (content LIKE '%'') OR 1 = 1 --%') /* 訳: テーブルmemosから、draftカラムがfalseでありかつcontentカラムが「') OR 1 = 1 --」を含むものを選択する。*/
SQLインジェクション対策前後で比べてみましょう。
対策前: SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 AND (content LIKE '%') OR 1 = 1 --%') 対策後: SELECT "memos".* FROM "memos" WHERE "memos"."draft" = 0 AND (content LIKE '%'') OR 1 = 1 --%')
違いがわかるでしょうか?
一つ目の%
の後のシングルクォーテーションが2つに変わりました。
これは、Rails内部でシングルクォーテーションをエスケープしていることに起因しています。
Ruby on Railsには、特殊なSQL文字をフィルタするしくみが組み込まれており、「'」「"」「NULL」「改行」をエスケープします。Rails セキュリティガイド - Railsガイド
対策後のクエリのWHERE
以降を分解してみましょう。
SQLでシングルクォーテーションを2つ記述すると、最初の'
が次の'
をエスケープします。
つまり対策後のコードでは、AND (content LIKE '%'') OR 1 = 1 --%')
の2つ目のシングルクォーテーションが3つ目をエスケープしています。そのため、'%') OR 1 = 1 --%'
全体が文字列とされ、SQLインジェクションが起こりません。
対策後のフォームで') OR 1 = 1 --
を検索してみます。
単純に「') OR 1 = 1 --
」という文字列を含むメモを検索するため、何も表示されなくなりました。
まとめ
where
の条件文字列の中に変数を直接置くと、SQLインジェクションの脆弱性があり危険である。- プレースホルダを使用して疑問符
?
を変数に置き換えることで、Railsが危険な文字をエスケープしてくれる。
感想
今回はサンプルアプリで実際にSQLインジェクションを発生させてみて、想像以上に簡単にデータが取り出せてしまうことに驚きました。
メソッドを正しく使えばRailsが自動で対策してくれることがわかったので、特にユーザが入力した文字列を使うときは、公式のドキュメントを読み込む癖をつけたいです。
また、データを処理するコードを書いた際に発行されているクエリを確認する重要性も再認識できました。to_sql
メソッドの使い方も覚えたので、今後は活用していきます。