【Ruby on Rails】検索画面の入力チェックをActive Modelで実装する

バックエンド

RailsのActiveModelのバリデーションってすごく便利ですが、DBに紐付かないモデルでもバリデーションが可能です。当記事では、検索画面でURLパラメータのバリデーションを行い、400 bat request画面を表示する方法を紹介します。

背景

先日、勤めている会社の運営サイトがSQLインジェクションを受けました。こういう攻撃ってなんとなく自分が遭遇するわけないと思い込んでしまいがちですよね。幸い被害はなかったのですが、本当にたまたま500エラーになってくれただけだったので、キチンと対策が必要だと改めて思いました。

SQLインジェクションを受けた時の状況

本番サイトで500エラーが発生するとslackに通知が来る仕組みになっていました。

ある朝、大量のエラー通知が飛んできて、エラーが流れるのが早すぎて追えないほど。あたふたしているうちに止まったのですが、エラーの内容を見ると、検索画面のクエリパラメータに以下のようなSQLが仕込まれていました。

1%' UNION ALL SELECT NULL,NULL,NULL,NULL,NULL,NULL,NULL#

一応、プリペアードステートメントを使っていたので危険なSQLは実行されていませんでしたが、想定外のパラメータだったため変なところでExceptionになっていました。

対策としてプリペアードステートメントだけでは不十分らしく、バリデーションしないとね、という事になりました。しかも全体的に見直したらプリペアードステートメントすらなくて文字列結合でSQLを生成しているところもあり………。

文字列結合でSQLを生成、絶対ダメ!!
たまたまプリペアードステートメントしているところが狙われて本当に良かった…。

また、この場合のhttpsのレスポンスは400 bat requestが適切です。バリデーションした結果エラーの場合は400画面を表示することになりました。

SQLインジェクションの手口

そもそも何が目的の攻撃?と思って調べてみました。裏の情報を抜き取ろうとしていたようです。以下のサイトが参考になります。
そろそろSQLインジェクションについてひとこと言っておくか。

本題

前置きが長くなってしまいましたが、以下のようなサンプルを使って解説していきます。

本を検索できる画面で、本のジャンルとタイトルで絞り込みができます。

検索対象のモデルBookは以下のようなテーブルです。

Books
id genre_id title
1 10 Ruby入門書
2 20 Rails中級者への道
3 30

よくわかるセキュリティ


入力値に対して以下のバリデーションを実装します。

ジャンル 任意項目 数値のみ
タイトル 任意項目 20文字以内

使用バージョン

Ruby 2.4
Rails 5.2.3

実装方法

以下は実装方法のざっくりとしたイメージです。

modelが2つ登場しているところがポイントです。modelAはバリデーションのみを実施し、modelBは普通のActiveRecordでDBへの問い合わせを行います。modelBがDBと対応しているのに対し、modelAはviewと対応しています。

viewの実装

form_tagを使えばDBとは関連のないフォームを作成することができます。検索結果の表示後も入力値を保持するように、valueも設定しておきます。

<h1>本の検索</h1>
<%= form_tag book_search_url, method: :get do %>
	<dl>
		<dt>ジャンル:</dt>
		<dd>
			<%= radio_button_tag(:genre_id, 0, params[:genre_id] == '0') %>指定なし
			<%= radio_button_tag(:genre_id, 10, params[:genre_id] == '10') %>小説
			<%= radio_button_tag(:genre_id, 20, params[:genre_id] == '20') %>漫画
			<%= radio_button_tag(:genre_id, 30, params[:genre_id] == '30') %>専門書
		</dd>
		<dt>タイトル:</dt>
		<dd><%= text_field_tag :title, params[:title] %></dd>
	</dl>
	<%= submit_tag '検索' %>
<% end %>

form_forでもできないか試したのですが難しかったです。ActiveModel::Attributesもincludeしないといけない…?うーん…できそうだけどなぁ…わかる方は教えて下さい。

modelAの実装

modelAはバリデーションのみを行うモデルです。models内に以下のファイルを作成しました。

class BookSearchParam
	include ActiveModel::Model

	attr_accessor :genre_id
	attr_accessor :title
	validates :genre_id, numericality: true, allow_blank: true
	validates :title, length: {maximum: 20}, allow_blank: true
end

ポイントはinclude ActiveModel::Modelです。ActiveModelはActiveRecordからバリデーションの機能の部分を抜き出したものです。ActiveRecordの上位クラスなのかな…?と思ったら、そういう事でもないようです。

controllerの実装

controllers内に以下のファイルを作成しました。

class BooksController < ApplicationController
	def search
		book_param = BookSearchParam.new(genre_id: params[:genre_id], title: params[:title])
		if book_param.invalid?
			render file: 'errors/400.html', status: :bad_request, content_type: 'text/html'
		end
	end
end

BookSearchParam.newでparamsを設定して、valid?またはinvalid?でバリデーションが実行されます。この例ではエラーの場合に400ページへ遷移します。もしエラーメッセージが欲しい場合は、book_param.errorsに格納されているのでそれを使用します。以下はrails consoleでエラーメッセージの表示を試した結果です。

irb(main):002:0> param = BookSearchParam.new(genre_id: 'a')
=> #<BookSearchParam:0x007ff5ad352578 @genre_id="a">

irb(main):003:0> param.valid?
=> false

irb(main):005:0> param.errors.details
=> {:genre_id=>[{:error=>:not_a_number, :value=>"a"}]}

実行してみる

rails consoleでは動作することが確認できましたが、画面からも動作を見ていきます。タイトルは20文字までなので、21文字入力して検索すると…

以下のように大変シンプルな400画面が出ましたね!

実運用のときには…
  • 検索画面にjsの入力チェックも入れましょう
  • 400ページには「ページを表示できません。URLに誤りがないか確認してください」などユーザに優しいメッセージを入れましょう

改良1 – renderを共通化

さて、この例では400.htmlをrenderしていますが、同様の検索画面がたくさんあり、同じことをしたい場合、毎回このrenderを書くのは冗長です。render_400のようなメソッドを上位のコントローラに作ると良いでしょう。

class ApplicationController < ActionController::Base
	def render_400
		render file: 'errors/400.html', status: :bad_request, content_type: 'text/html'
	end
end
class BooksController < ApplicationController
	def search
		book_param = BookSearchParam.new(genre_id: params[:genre_id], title: params[:title])
		if book_param.invalid?
			render_400
		end
	end
end

改良2 – 開発環境ではエラー画面を表示

ここまでで完成でも良いのですが、以下の問題点があります。

  • 400ページだけではエラー内容がわからない。本番環境はそれで良いが、開発中は困る。
  • render_500やrender_404なども存在している場合に、毎回どれを呼び出すか迷ってしまう。

上記を解決するために以下のように改修します。

  • バリデーションエラーの時はExceptionとなるようにBookSearchParamを修正
  • Exceptionにはエラーメッセージを格納
  • 該当のExceptionは上位のコントローラでrescueし、本番環境では400ページを表示し、開発環境ではExceptionを放置することでエラー画面を表示する

修正後のコードは以下です。

class BookSearchParam
	include ActiveModel::Model

	attr_accessor :genre_id
	attr_accessor :title
	validates :genre_id, numericality: true, allow_blank: true
	validates :title, length: {maximum: 20}, allow_blank: true

	def strict_valid!
		if invalid?
			raise ActiveModel::StrictValidationFailed, errors.full_messages
		end
	end
end
class BooksController < ApplicationController
	def search
		book_param = BookSearchParam.new(genre_id: params[:genre_id], title: params[:title])
		book_param.strict_valid!
end
class ApplicationController < ActionController::Base
	unless Rails.env.development?
		rescue_from ActiveModel::StrictValidationFailed, with: :render_400
	end

	def render_400
		render file: 'errors/400.html', status: :bad_request, content_type: 'text/html'
	end
end

開発環境でエラーになると以下の画面が表示されます。

エラーメッセージが表示されていてデバッグしやすいですね。

似たような方法ですが、railsのvalidationにはstrictオプションというものがあり、これもバリデーションエラーの時にExceptionになります。こちらを採用しなかった理由は、複数のバリデーションがある時に一番最初にエラーになった内容しか表示してくれないからです。今回はすべてのバリデーションエラーを表示したいため、このような実装にしました。

参考サイト

タイトルとURLをコピーしました