mybook

入力バリデーション — 信頼できないデータから守る

「ちょっと待て、これ本物か?」

リョウはセキュリティスキャンのレポートを二度見した。在庫検索APIのログに、不穏な文字列が残っていた。

GET /api/v1/inventory?name='; DROP TABLE inventory_items; --
GET /api/v1/inventory?name=<script>alert('xss')</script>
GET /api/v1/inventory?name=../../../../etc/passwd

誰かがシステムを探っていた。SQLインジェクション、XSS、パストラバーサル——教科書で見た攻撃が実際に試されていた。

SQLインジェクション

SQLインジェクションは、ユーザー入力がSQLクエリに直接埋め込まれることで発生する。

# 脆弱なコード(絶対にやってはいけない)
def search
  name = params[:name]
  # 文字列補間でSQLを組み立てている
  items = InventoryItem.where("name LIKE '%#{name}%'")
  render json: items
end
 
# 攻撃者が送るリクエスト
# GET /api/v1/inventory?name='; DROP TABLE inventory_items; --
# 実行されるSQL:
# SELECT * FROM inventory_items WHERE name LIKE '%'; DROP TABLE inventory_items; --%'

WARNING

RailsのActiveRecordを使っていても、文字列補間でSQL条件を組み立てると脆弱になります。プレースホルダーを使うことで、入力値はSQLコードとして解釈されません。

# 安全なコード
def search
  name = params[:name]
  # プレースホルダー(?)を使う
  items = InventoryItem.where("name LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(name)}%")
  render json: items
end
 
# さらに良い:ActiveRecordのスコープを使う
class InventoryItem < ApplicationRecord
  scope :search_by_name, ->(name) {
    where("name ILIKE ?", "%#{sanitize_sql_like(name)}%")
  }
end
# 複雑なクエリにはArelを使う
def complex_search
  items = InventoryItem.where(
    InventoryItem.arel_table[:name].matches("%#{ActiveRecord::Base.sanitize_sql_like(params[:name])}%")
      .and(InventoryItem.arel_table[:quantity].gt(params[:min_quantity].to_i))
  )
  render json: items
end

強力な入力バリデーション

すべての入力を検証することが基本だ。

# app/models/inventory_item.rb
class InventoryItem < ApplicationRecord
  # ホワイトリスト方式のバリデーション
  validates :name,
    presence: true,
    length: { minimum: 1, maximum: 255 },
    format: {
      with: /\A[a-zA-Z0-9\s\-_.()()]+\z/,
      message: "can only contain alphanumeric characters, spaces, and basic punctuation"
    }
 
  validates :quantity,
    presence: true,
    numericality: {
      only_integer: true,
      greater_than_or_equal_to: 0,
      less_than_or_equal_to: 1_000_000
    }
 
  validates :sku,
    presence: true,
    uniqueness: { scope: :company_id },
    format: {
      with: /\ASKU-[A-Z0-9]{8}\z/,
      message: "must be in format SKU-XXXXXXXX"
    }
 
  validates :price,
    numericality: {
      greater_than_or_equal_to: 0,
      less_than: 1_000_000
    }
end
# app/controllers/api/v1/inventory_controller.rb
class Api::V1::InventoryController < ApplicationController
  def create
    # Strong Parametersで許可フィールドを明示的に指定
    item = InventoryItem.new(inventory_params)
    item.company = current_company  # 必ずサーバー側で設定
 
    if item.save
      render json: item, status: :created
    else
      render json: { errors: item.errors }, status: :unprocessable_entity
    end
  end
 
  private
 
  def inventory_params
    params.require(:inventory_item).permit(
      :name, :sku, :quantity, :price, :description, :category_id
      # company_id は含めない(サーバー側で設定)
    )
  end
end

XSSの防止

XSSはAPIの文脈では、クライアントがAPIレスポンスをHTMLに挿入する際に発生する。

# app/serializers/inventory_item_serializer.rb
class InventoryItemSerializer < ActiveModel::Serializer
  attributes :id, :name, :description, :sku, :quantity, :price
 
  def name
    # HTMLエスケープ(Railsは自動でやってくれるが、明示的に)
    ERB::Util.html_escape(object.name)
  end
 
  def description
    # リッチテキストが必要な場合はサニタイズ
    ActionView::Base.full_sanitizer.sanitize(object.description)
  end
end
# config/application.rb
module Stockflow
  class Application < Rails::Application
    # Content Security Policy(CSP)の設定
    # APIのレスポンスではなく、管理画面などのHTML用
    config.content_security_policy do |policy|
      policy.default_src :self
      policy.script_src :self, :https
      policy.style_src :self, :https, :unsafe_inline
      policy.img_src :self, :https, :data
      policy.connect_src :self, :https
      policy.font_src :self, :https, :data
      policy.object_src :none
      policy.frame_ancestors :none  # クリックジャッキング対策
    end
 
    # ノンスを使った動的なCSP
    config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
    config.content_security_policy_nonce_directives = %w[script-src]
  end
end

CSRF対策

API(SPA + API構成)でのCSRF対策はトークン方式が一般的だ。

Loading diagram...
# config/application.rb
module Stockflow
  class Application < Rails::Application
    # JWT認証を使うAPIはCSRFトークン不要だが
    # Cookieベース認証(セッション)を使う場合は必要
 
    # SameSite Cookie + Origin検証で対策
    config.action_dispatch.cookies_same_site_protection = :strict
  end
end
# app/controllers/concerns/csrf_protection.rb
module CsrfProtection
  extend ActiveSupport::Concern
 
  included do
    before_action :verify_origin!
  end
 
  private
 
  def verify_origin!
    return if request.get? || request.head?
 
    allowed_origins = [
      "https://app.stockflow.example.com",
      "https://admin.stockflow.example.com"
    ]
 
    allowed_origins.push("http://localhost:3000") if Rails.env.development?
 
    origin = request.headers['Origin'] || request.headers['Referer']
 
    unless origin && allowed_origins.any? { |allowed| origin.start_with?(allowed) }
      render json: { error: "Forbidden: Invalid origin" }, status: :forbidden
    end
  end
end

パストラバーサル攻撃の防止

ファイルパスを扱う場合、攻撃者は../を使って意図しないディレクトリにアクセスしようとする。

# app/controllers/api/v1/exports_controller.rb
class Api::V1::ExportsController < ApplicationController
  ALLOWED_EXPORT_DIR = Rails.root.join('exports').to_s
 
  def download
    filename = params[:filename]
 
    # ベースラインとなるディレクトリを正規化
    safe_dir = File.realpath(ALLOWED_EXPORT_DIR)
 
    # リクエストされたパスを正規化(../を解決)
    requested_path = File.expand_path(filename, safe_dir)
 
    # 正規化されたパスが許可ディレクトリ内にあることを確認
    unless requested_path.start_with?(safe_dir)
      render json: { error: "Forbidden: Invalid file path" }, status: :forbidden
      return
    end
 
    unless File.exist?(requested_path) && File.file?(requested_path)
      render json: { error: "File not found" }, status: :not_found
      return
    end
 
    send_file requested_path, disposition: 'attachment'
  end
end

大量データ送信(Mass Assignment)の防止

# 脆弱なコード
def update
  @user.update(params[:user])  # 全パラメータを許可 → is_admin=trueも通ってしまう
end
 
# 安全なコード
def update
  @user.update(user_params)
end
 
private
 
def user_params
  # 通常ユーザーが変更できるフィールドのみ
  permitted = params.require(:user).permit(:name, :email, :bio)
 
  # 管理者のみ追加フィールドを許可
  if current_user.admin?
    permitted.merge(params.require(:user).permit(:role, :company_id))
  else
    permitted
  end
end

ファイルアップロードの安全化

# app/models/document.rb
class Document < ApplicationRecord
  # Active StorageでのファイルアップロードのバリデーT
  has_one_attached :file
 
  ALLOWED_CONTENT_TYPES = %w[
    application/pdf
    image/jpeg
    image/png
    image/gif
    text/csv
    application/vnd.ms-excel
  ].freeze
 
  MAX_FILE_SIZE = 10.megabytes
 
  validate :validate_file
 
  private
 
  def validate_file
    return unless file.attached?
 
    # ファイルサイズのチェック
    if file.blob.byte_size > MAX_FILE_SIZE
      errors.add(:file, "is too large (maximum is #{MAX_FILE_SIZE / 1.megabyte}MB)")
    end
 
    # MIMEタイプをマジックバイトで検証(拡張子だけでは不十分)
    content_type = Marcel::MimeType.for(file.blob.download.first(1024))
    unless ALLOWED_CONTENT_TYPES.include?(content_type)
      errors.add(:file, "must be a PDF, image, CSV, or Excel file")
    end
  end
end
# app/controllers/api/v1/documents_controller.rb
class Api::V1::DocumentsController < ApplicationController
  def create
    @document = current_user.documents.new(document_params)
 
    if @document.save
      # ウイルススキャン(非同期)
      VirusScanJob.perform_later(@document.id)
 
      render json: @document, status: :created
    else
      render json: { errors: @document.errors }, status: :unprocessable_entity
    end
  end
 
  private
 
  def document_params
    params.require(:document).permit(:name, :file)
  end
end
# app/jobs/virus_scan_job.rb
class VirusScanJob < ApplicationJob
  def perform(document_id)
    document = Document.find(document_id)
    file_content = document.file.blob.download
 
    # ClamAVでウイルススキャン(要: clamav gem)
    result = ClamAV::Connection.new.scan_io(StringIO.new(file_content))
 
    if result.positive?
      document.file.purge
      document.update!(scan_status: 'infected')
      # アラート通知
      SecurityAlertMailer.virus_detected(document).deliver_later
    else
      document.update!(scan_status: 'clean')
    end
  end
end

JSONスキーマバリデーション

複雑なAPIリクエストにはJSONスキーマバリデーションが有効だ。

# Gemfile
gem 'json_schemer'
 
# app/validators/json_schema_validator.rb
class JsonSchemaValidator
  SCHEMAS = {
    create_inventory: {
      type: "object",
      required: %w[name sku quantity price],
      properties: {
        name: { type: "string", minLength: 1, maxLength: 255, pattern: "^[a-zA-Z0-9\\s\\-_.]+$" },
        sku: { type: "string", pattern: "^SKU-[A-Z0-9]{8}$" },
        quantity: { type: "integer", minimum: 0, maximum: 1_000_000 },
        price: { type: "number", minimum: 0, maximum: 1_000_000 },
        description: { type: "string", maxLength: 2000 }
      },
      additionalProperties: false  # 未知のフィールドを拒否
    }.freeze
  }.freeze
 
  def self.validate!(schema_name, data)
    schema = SCHEMAS[schema_name]
    raise ArgumentError, "Unknown schema: #{schema_name}" unless schema
 
    result = JSONSchemer.schema(schema).validate(data)
    errors = result.to_a
 
    if errors.any?
      error_messages = errors.map { |e| "#{e['data_pointer']}: #{e['details']}" }
      raise ValidationError.new("Validation failed", error_messages)
    end
  end
end

AWS WAFでの追加防御

アプリケーション層の対策に加えて、WAFで攻撃をフィルタリングする。

# terraform/waf.tf(概念例)
resource "aws_wafv2_web_acl" "api" {
  name  = "stockflow-api-waf"
  scope = "REGIONAL"
 
  # AWSマネージドルールグループ(自動的に更新される)
  rule {
    name     = "AWSManagedRulesCommonRuleSet"
    priority = 1
    override_action { none {} }
    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesCommonRuleSet"
        vendor_name = "AWS"
        # SQLインジェクション、XSSを検出するルールを除外しない
      }
    }
  }
 
  rule {
    name     = "AWSManagedRulesSQLiRuleSet"
    priority = 2
    override_action { none {} }
    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesSQLiRuleSet"
        vendor_name = "AWS"
      }
    }
  }
 
  # カスタムルール:過大なペイロードをブロック
  rule {
    name     = "BlockLargePayloads"
    priority = 3
    action { block {} }
    statement {
      size_constraint_statement {
        field_to_match { body {} }
        comparison_operator = "GT"
        size                = 10240  # 10KB
        text_transformation { priority = 0; type = "NONE" }
      }
    }
  }
}

INFO

AWS WAFのマネージドルールグループはAWSのセキュリティチームが継続的に更新します。自前でSQLインジェクションパターンをメンテナンスするより効果的で、新しい攻撃パターンにも自動対応します。

チェックリスト

  • ActiveRecordでプレースホルダーを使用している(文字列補間禁止)
  • Strong Parametersで許可フィールドを明示的に指定している
  • すべての入力フィールドにバリデーションを設定している
  • ファイルアップロードでMIMEタイプをマジックバイトで検証している
  • ファイルパスのトラバーサル攻撃を防ぐ検証を実装している
  • JSONスキーマバリデーションで構造を検証している
  • additionalProperties: falseで未知フィールドを拒否している
  • AWS WAFのマネージドルールでSQLi/XSSを検出している
  • アップロードファイルのウイルススキャンを実装している