Skip to content

Commit

Permalink
Add cookie_flags plugin, for overriding, warning, or raising for inco…
Browse files Browse the repository at this point in the history
…rrect cookie flags
  • Loading branch information
jeremyevans committed Nov 21, 2023
1 parent 855f84c commit 6eddd39
Show file tree
Hide file tree
Showing 4 changed files with 370 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
= master

* Add cookie_flags plugin, for overriding, warning, or raising for incorrect cookie flags (jeremyevans)

= 3.74.0 (2023-11-13)

* Add redirect_http_to_https plugin, helping to ensure future requests from the browser are submitted via HTTPS (jeremyevans)
Expand Down
157 changes: 157 additions & 0 deletions lib/roda/plugins/cookie_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# 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
208 changes: 208 additions & 0 deletions spec/plugin/cookie_flags_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
require_relative "../spec_helper"

describe "cookie_flags plugin" do
exception_class = Class.new(StandardError)

before do
app(:bare) do
plugin :cookies
plugin :cookie_flags
route do |r|
r.get String, String, String do |secure, httponly, samesite|
h = {:value=>'b', :secure=>secure == 'secure', :httponly=>httponly == 'httponly'}
h[:same_site] = samesite.to_sym unless samesite == 'none'
response.set_cookie('a', h)
body = response.headers[RodaResponseHeaders::SET_COOKIE]
response.set_cookie('b', h) if r.GET['2']
body
end

r.get 'raise' do
raise exception_class
end

''
end
end
end

it "does not modify flags if they are set correctly" do
_, h, b = req('/secure/httponly/strict')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
b = b.join
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i)
h.must_equal b if Rack.release >= '1.6.5'
end

it "does not modify flags if they are set correctly when using multiple cookies" do
_, h, b = req('/secure/httponly/strict', 'QUERY_STRING'=>'2=2')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
b = b.join
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i)
end

it "modifies flags if they are set correctly" do
_, h, b = req('/nosecure/nohttponly/lax')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite=strict/i)
b.must_match(/samesite=lax/i) if Rack.release >= '1.6.5'
end

it "modifies flags if they are set correctly when using multiple cookies" do
_, h, b = req('/nosecure/nohttponly/lax')
h[RodaResponseHeaders::SET_COOKIE].each do |h|
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i)
end
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite=strict/i)
b.must_match(/samesite=lax/i)
end if Rack.release >= '3'

it "adds samesite entry if configured and not present" do
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports checking only secure flag" do
@app.plugin :cookie_flags, :httponly=>false, :same_site=>nil
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.wont_match(/httponly/i)
h.wont_match(/samesite=strict/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports checking only httponly flag" do
@app.plugin :cookie_flags, :secure=>false, :same_site=>nil
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.wont_match(/secure/i)
h.must_match(/httponly/i)
h.wont_match(/samesite=strict/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports checking only samesite flag" do
@app.plugin :cookie_flags, :httponly=>false, :secure=>nil
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.wont_match(/secure/i)
h.wont_match(/httponly/i)
h.must_match(/samesite=strict/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports enforcing samesite=lax" do
@app.plugin :cookie_flags, :httponly=>false, :secure=>nil, :same_site=>:lax
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.wont_match(/secure/i)
h.wont_match(/httponly/i)
h.must_match(/samesite=lax/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports enforcing samesite=none, which also turns on secure" do
@app.plugin :cookie_flags, :httponly=>false, :secure=>nil, :same_site=>:none
_, h, b = req('/nosecure/nohttponly/none')
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.wont_match(/httponly/i)
h.must_match(/samesite=none/i)
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.wont_match(/samesite/i)
end

it "supports :warn_and_modify action" do
s = nil
@app.plugin :cookie_flags, :action=>:warn_and_modify
@app.send(:define_method, :warn){|msg| s = msg}
_, h, b = req('/nosecure/nohttponly/strict')
s.must_match(/Response contains cookie with unexpected flags:.*Expecting the following cookie flags: secure httponly/)
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.must_match(/samesite=strict/i) if Rack.release >= '1.6.5'
b = b.join
b.wont_match(/secure/i)
b.wont_match(/httponly/i)
b.must_match(/samesite=strict/i) if Rack.release >= '1.6.5'
end

it "supports :warn action" do
s = nil
@app.plugin :cookie_flags, :action=>:warn
@app.send(:define_method, :warn){|msg| s = msg}
_, h, b = req('/secure/httponly/lax')
s.must_match(/Response contains cookie with unexpected flags:.*Expecting the following cookie flags: samesite=strict/)
h = h[RodaResponseHeaders::SET_COOKIE]
h = h[0] if h.is_a?(Array)
h.must_match(/secure/i)
h.must_match(/httponly/i)
h.wont_match(/samesite=strict/i)
h.must_equal b.join
end

it "supports :error action" do
@app.plugin :cookie_flags, :action=>:raise
e = proc{req('/secure/httponly/none')}.must_raise(Roda::RodaPlugins::CookieFlags::Error)
e.message.must_match(/Response contains cookie with unexpected flags:.*Expecting the following cookie flags: samesite=strict/)
end

it "should not break when exceptions are raised by app" do
proc{req('/raise')}.must_raise(exception_class)
end

it "should handle response without cookies set" do
s, h, b = req
s.must_equal 200
h[RodaResponseHeaders::SET_COOKIE].must_be_nil
b.must_equal ['']
end
end
1 change: 1 addition & 0 deletions www/pages/documentation.erb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<li>Request/Response: <ul>
<li><a href="rdoc/classes/Roda/RodaPlugins/Caching.html">caching</a>: Adds request and response methods related to http caching.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/ContentSecurityPolicy.html">content_security_policy</a>: Allows setting an appropriate Content-Security-Policy header for the application/branch/action.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/CookieFlags.html">cookie_flags</a>: Adds checks for certain cookie flags, to update, warn, or error if they are not set correctly.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/Cookies.html">cookies</a>: Adds response methods for handling cookies.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/DefaultHeaders.html">default_headers</a>: Allows modifying the default headers for responses.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/DefaultStatus.html">default_status</a>: Allows overriding the default status for responses.</li>
Expand Down

0 comments on commit 6eddd39

Please sign in to comment.