forked from soveran/cuba
-
Notifications
You must be signed in to change notification settings - Fork 140
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add cookie_flags plugin, for overriding, warning, or raising for inco…
…rrect cookie flags
- Loading branch information
1 parent
855f84c
commit 6eddd39
Showing
4 changed files
with
370 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters