入力バリデーション — 信頼できないデータから守る
「ちょっと待て、これ本物か?」
リョウはセキュリティスキャンのレポートを二度見した。在庫検索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
endXSSの防止
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
endCSRF対策
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
endJSONスキーマバリデーション
複雑な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
endAWS 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を検出している
- アップロードファイルのウイルススキャンを実装している