# 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