lib/roda/plugins/cookie_flags.rb
# frozen-string-literal: true # class Roda module RodaPlugins # The cookie_flags plugin allows users to force specific cookie flags for # all cookies set by the application. It can also be used to warn or # raise for unexpected cookie flags. # # The cookie_flags plugin deals with the following cookie flags: # # httponly :: Disallows access to the cookie from client-side scripts. # samesite :: Restricts to which domains the cookie is sent. # secure :: Instructs the browser to only transmit the cookie over HTTPS. # # This plugin ships in secure-by-default mode, where it enforces # secure, httponly, samesite=strict cookies. You can disable enforcing # specific flags using the following options: # # :httponly :: Set to false to not enforce httponly flag. # :same_site :: Set to symbol or string to enforce a different samesite # setting, or false to not enforce a specific samesite setting. # :secure :: Set to false to not enforce secure flag. # # For example, to enforce secure cookies and enforce samesite=lax, but not enforce # an httponly flag: # # plugin :cookie_flags, httponly: false, same_site: 'lax' # # In general, overriding cookie flags using this plugin should be considered a # stop-gap solution. Instead of overriding cookie flags, it's better to fix # whatever is setting the cookie flags incorrectly. You can use the :action # option to modify the behavior: # # # Issue warnings when modifying cookie flags # plugin :cookie_flags, action: :warn_and_modify # # # Issue warnings for incorrect cookie flags without modifying cookie flags # plugin :cookie_flags, action: :warn # # # Raise errors for incorrect cookie flags # plugin :cookie_flags, action: :raise # # The recommended way to use the plugin is to use it only during testing with # <tt>action: :raise</tt>. Then as long as you have fully covering tests, you # can be sure the cookies set by your application use the correct flags. # # Note that this plugin only affects cookies set by the application, and does not # affect cookies set by middleware the application is using. module CookieFlags # :nocov: MATCH_METH = RUBY_VERSION >= '2.4' ? :match? : :match # :nocov: private_constant :MATCH_METH DEFAULTS = {:secure=>true, :httponly=>true, :same_site=>'strict', :action=>:modify}.freeze private_constant :DEFAULTS # Error class raised for action: :raise when incorrect cookie flags are used. class Error < RodaError end def self.configure(app, opts=OPTS) previous = app.opts[:cookie_flags] || DEFAULTS opts = app.opts[:cookie_flags] = previous.merge(opts) case opts[:same_site] when String, Symbol opts[:same_site] = opts[:same_site].to_s.downcase.freeze opts[:same_site_string] = "; samesite=#{opts[:same_site]}".freeze opts[:secure] = true if opts[:same_site] == 'none' end opts.freeze end module InstanceMethods private def _handle_cookie_flags_array(cookies) opts = self.class.opts[:cookie_flags] needs_secure = opts[:secure] needs_httponly = opts[:httponly] if needs_same_site = opts[:same_site] same_site_string = opts[:same_site_string] same_site_regexp = /;\s*samesite\s*=\s*(\S+)\s*(?:\z|;)/i end action = opts[:action] cookies.map do |cookie| if needs_secure add_secure = !/;\s*secure\s*(?:\z|;)/i.send(MATCH_METH, cookie) end if needs_httponly add_httponly = !/;\s*httponly\s*(?:\z|;)/i.send(MATCH_METH, cookie) end if needs_same_site has_same_site = same_site_regexp.match(cookie) unless add_same_site = !has_same_site update_same_site = needs_same_site != has_same_site[1].downcase end end next cookie unless add_secure || add_httponly || add_same_site || update_same_site case action when :raise, :warn, :warn_and_modify message = "Response contains cookie with unexpected flags: #{cookie.inspect}." \ "Expecting the following cookie flags: "\ "#{'secure ' if add_secure}#{'httponly ' if add_httponly}#{same_site_string[2..-1] if add_same_site || update_same_site}" if action == :raise raise Error, message else warn(message) next cookie if action == :warn end end if update_same_site cookie = cookie.gsub(same_site_regexp, same_site_string) else cookie = cookie.dup cookie << same_site_string if add_same_site end cookie << '; secure' if add_secure cookie << '; httponly' if add_httponly cookie end end if Rack.release >= '3' def _handle_cookie_flags(cookies) cookies = [cookies] if cookies.is_a?(String) _handle_cookie_flags_array(cookies) end else def _handle_cookie_flags(cookie_string) _handle_cookie_flags_array(cookie_string.split("\n")).join("\n") end end # Handle cookie flags in response def _roda_after_85__cookie_flags(res) return unless res && (headers = res[1]) && (value = headers[RodaResponseHeaders::SET_COOKIE]) headers[RodaResponseHeaders::SET_COOKIE] = _handle_cookie_flags(value) end end end register_plugin(:cookie_flags, CookieFlags) end end