# frozen_string_literal: true
require "hanami/utils/blank"
require "hanami/controller/error"
require "rack/utils"
require "securerandom"
module Hanami
# @api private
class Action
# Invalid CSRF Token
#
# @since 0.4.0
class InvalidCSRFTokenError < Controller::Error
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. POST, PATCH etc..), we should send a special param:
# _csrf_token.
#
# If the param matches with the challenge token, the flow can continue.
# Otherwise the application detects an attack attempt, it reset the session
# and Hanami::Action::InvalidCSRFTokenError is raised.
#
# We can specify a custom handling strategy, by overriding #handle_invalid_csrf_token.
#
# Form helper (#form_for) automatically sets a hidden field with the
# correct token. A special view method (#csrf_token) is available in
# case the form markup is manually crafted.
#
# We can disable this check on action basis, by overriding #verify_csrf_token?.
#
# @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 < Web::Action
# def handl(*)
# # ...
# 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 < Web::Action
# def handle(*)
# # ...
# end
#
# private
#
# def verify_csrf_token?(req, res)
# false
# end
# end
# end
module CSRFProtection
# Session and params key for CSRF token.
#
# This key is shared with hanami-controller and hanami-helpers
#
# @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[
Action::GET => true,
Action::HEAD => true,
Action::TRACE => true,
Action::OPTIONS => true
].freeze
# @since 0.4.0
# @api private
def self.included(action)
unless Hanami.respond_to?(:env?) && Hanami.env?(:test)
action.class_eval do
before :set_csrf_token, :verify_csrf_token
end
end
end
private
# Set CSRF Token in session
#
# @since 0.4.0
# @api private
def set_csrf_token(_req, res)
res.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 #verify_csrf_token?.
# For custom handling of an attack, please override #handle_invalid_csrf_token.
#
# @since 0.4.0
# @api private
def verify_csrf_token(req, res)
handle_invalid_csrf_token(req, res) if invalid_csrf_token?(req, res)
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?(req, res)
return false unless verify_csrf_token?(req, res)
missing_csrf_token?(req, res) ||
!::Rack::Utils.secure_compare(req.session[CSRF_TOKEN], req.params[CSRF_TOKEN])
end
# Verify the CSRF token was passed in params.
#
# @api private
def missing_csrf_token?(req, *)
Hanami::Utils::Blank.blank?(req.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 false if you want to bypass security check.
#
# @since 0.4.0
#
# @example
# module Web::Controllers::Books
# class Create < Web::Action
# def call(*)
# # ...
# end
#
# private
#
# def verify_csrf_token?(req, res)
# false
# end
# end
# end
def verify_csrf_token?(req, *)
!IDEMPOTENT_HTTP_METHODS[req.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
#
# @example
# module Web::Controllers::Books
# class Create < Web::Action
# def call(*)
# # ...
# end
#
# private
#
# def handle_invalid_csrf_token(req, res)
# # custom invalid CSRF management goes here
# end
# end
# end
def handle_invalid_csrf_token(*, res)
res.session.clear
raise InvalidCSRFTokenError
end
end
end
end