hanami/lib/hanami/action/csrf_protection.rb

168 lines
4.6 KiB
Ruby

require 'securerandom'
module Hanami
module Action
# Invalid CSRF Token
#
# @since 0.4.0
class InvalidCSRFTokenError < ::StandardError
end
# CSRF Protection
#
# This security mechanism is enabled automatically if sessions are turned on.
#
# It stores a "challenge" token in session. For each "state changing request"
# (eg. <tt>POST</tt>, <tt>PATCH</tt> etc..), we should send a special param:
# <tt>_csrf_token</tt>.
#
# If the param matches with the challenge token, the flow can continue.
# Otherwise the application detects an attack attempt, it reset the session
# and <tt>Hanami::Action::InvalidCSRFTokenError</tt> is raised.
#
# We can specify a custom handling strategy, by overriding <tt>#handle_invalid_csrf_token</tt>.
#
# Form helper (<tt>#form_for</tt>) automatically sets a hidden field with the
# correct token. A special view method (<tt>#csrf_token</tt>) is available in
# case the form markup is manually crafted.
#
# We can disable this check on action basis, by overriding <tt>#verify_csrf_token?</tt>.
#
# @since 0.4.0
#
# @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29
# @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
#
# @example Custom Handling
# module Web::Controllers::Books
# class Create
# include Web::Action
#
# def call(params)
# # ...
# end
#
# private
#
# def handle_invalid_csrf_token
# Web::Logger.warn "CSRF attack: expected #{ session[:_csrf_token] }, was #{ params[:_csrf_token] }"
# # manual handling
# end
# end
# end
#
# @example Bypass Security Check
# module Web::Controllers::Books
# class Create
# include Web::Action
#
# def call(params)
# # ...
# end
#
# private
#
# def verify_csrf_token?
# false
# end
# end
# end
module CSRFProtection
# Session and params key for CSRF token.
#
# This key is shared with <tt>hanami-controller</tt> and <tt>hanami-helpers</tt>
#
# @since 0.4.0
# @api private
CSRF_TOKEN = :_csrf_token
# Idempotent HTTP methods
#
# By default, the check isn't performed if the request method is included
# in this list.
#
# @since 0.4.0
# @api private
IDEMPOTENT_HTTP_METHODS = Hash[
'GET' => true,
'HEAD' => true,
'TRACE' => true,
'OPTIONS' => true
].freeze
# @since 0.4.0
# @api private
def self.included(action)
action.class_eval do
before :set_csrf_token, :verify_csrf_token
end unless Hanami.env?(:test)
end
private
# Set CSRF Token in session
#
# @since 0.4.0
# @api private
def set_csrf_token
session[CSRF_TOKEN] ||= generate_csrf_token
end
# Verify if CSRF token from params, matches the one stored in session.
# If not, it raises an error.
#
# Don't override this method.
#
# To bypass the security check, please override <tt>#verify_csrf_token?</tt>.
# For custom handling of an attack, please override <tt>#handle_invalid_csrf_token</tt>.
#
# @since 0.4.0
# @api private
def verify_csrf_token
handle_invalid_csrf_token if invalid_csrf_token?
end
# Verify if CSRF token from params, matches the one stored in session.
#
# Don't override this method.
#
# @since 0.4.0
# @api private
def invalid_csrf_token?
verify_csrf_token? &&
! ::Rack::Utils.secure_compare(session[CSRF_TOKEN], params[CSRF_TOKEN])
end
# Generates a random CSRF Token
#
# @since 0.4.0
# @api private
def generate_csrf_token
SecureRandom.hex(32)
end
# Decide if perform the check or not.
#
# Override and return <tt>false</tt> if you want to bypass security check.
#
# @since 0.4.0
def verify_csrf_token?
!IDEMPOTENT_HTTP_METHODS[request_method]
end
# Handle CSRF attack.
#
# The default policy resets the session and raises an exception.
#
# Override this method, for custom handling.
#
# @raise [Hanami::Action::InvalidCSRFTokenError]
#
# @since 0.4.0
def handle_invalid_csrf_token
session.clear
raise InvalidCSRFTokenError.new
end
end
end
end