rack-protection 2.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3268bb2b60f8095b38658717f5e267da2e1dfbee57f487baf39a185d3cf9266
4
- data.tar.gz: fc40122b95963a81333da038536782d85a9abdc92b823eb5d3044ef3c5c807c4
3
+ metadata.gz: 7640a15f8659807abd53474e7ce538a42e476e4bd99dc745f3b9b8c16161c008
4
+ data.tar.gz: '05468ec6c8113d3afce2df62221e4c866616999700c30ba3ef94a2705b11138b'
5
5
  SHA512:
6
- metadata.gz: 7b381c903bb99d1e8cfcd00554642aafc2644f4432987be95e094a84b0f020648efb92b4a48e082cda5749045c44d14e5f08d97a1a733bf2b1e850eeaf69d67b
7
- data.tar.gz: bfb60cf9484528f0096cd68a6da55b66fa3471407a120caff83ee961b54043f3e4aca45a219273e7e48cc85ce70742ee75dab750609239fb887dfc35dfbcae59
6
+ metadata.gz: eeaff5e584a8ee3be6c80dc92c67fcc95bdbb97b084509ed90ca9ad524598fba63690cfa372586edd27940cd609fa44210637e9c95fbf1191e1a5cc297f222ac
7
+ data.tar.gz: 26e2160d65b6015c7aaa52266b7241d15f645eb259d1371b864b3e2b6a3b1fbef841e62304bc8e39a83fab1a52ddb0c3455a51385a9a451c833cbed91b75d00a
data/Gemfile CHANGED
@@ -1,13 +1,16 @@
1
- source "https://rubygems.org"
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
2
4
  # encoding: utf-8
3
5
 
4
6
  gem 'rake'
7
+ gem 'rspec', '~> 3'
5
8
 
6
9
  rack_version = ENV['rack'].to_s
7
- rack_version = nil if rack_version.empty? or rack_version == 'stable'
8
- rack_version = {:github => 'rack/rack'} if rack_version == 'master'
10
+ rack_version = nil if rack_version.empty? || (rack_version == 'stable')
11
+ rack_version = { github: 'rack/rack' } if rack_version == 'head'
9
12
  gem 'rack', rack_version
10
13
 
11
- gem 'sinatra', path: '..'
12
-
13
14
  gemspec
15
+
16
+ gem 'rack-test'
data/README.md CHANGED
@@ -74,6 +74,7 @@ Prevented by:
74
74
  ## Cookie Tossing
75
75
 
76
76
  Prevented by:
77
+
77
78
  * [`Rack::Protection::CookieTossing`][cookie-tossing] (not included by `use Rack::Protection`)
78
79
 
79
80
  ## IP Spoofing
@@ -95,6 +96,7 @@ Prevented by:
95
96
  # Instrumentation
96
97
 
97
98
  Instrumentation is enabled by passing in an instrumenter as an option.
99
+
98
100
  ```
99
101
  use Rack::Protection, instrumenter: ActiveSupport::Notifications
100
102
  ```
data/Rakefile CHANGED
@@ -1,53 +1,55 @@
1
- # encoding: utf-8
2
- $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path('lib', __dir__)
3
4
 
4
5
  begin
5
6
  require 'bundler'
6
7
  Bundler::GemHelper.install_tasks
7
8
  rescue LoadError => e
8
- $stderr.puts e
9
+ warn e
9
10
  end
10
11
 
11
- desc "run specs"
12
- task(:spec) { ruby '-S rspec spec' }
12
+ desc 'run specs'
13
+ task(:spec) { ruby '-S rspec' }
13
14
 
14
15
  namespace :doc do
15
16
  task :readmes do
16
17
  Dir.glob 'lib/rack/protection/*.rb' do |file|
17
18
  excluded_files = %w[lib/rack/protection/base.rb lib/rack/protection/version.rb]
18
19
  next if excluded_files.include?(file)
20
+
19
21
  doc = File.read(file)[/^ module Protection(\n)+( #[^\n]*\n)*/m].scan(/^ *#(?!#) ?(.*)\n/).join("\n")
20
- file = "doc/#{file[4..-4].tr("/_", "-")}.rdoc"
21
- Dir.mkdir "doc" unless File.directory? "doc"
22
+ file = "doc/#{file[4..-4].tr('/_', '-')}.rdoc"
23
+ Dir.mkdir 'doc' unless File.directory? 'doc'
22
24
  puts "writing #{file}"
23
- File.open(file, "w") { |f| f << doc }
25
+ File.open(file, 'w') { |f| f << doc }
24
26
  end
25
27
  end
26
28
 
27
29
  task :index do
28
- doc = File.read("README.md")
29
- file = "doc/rack-protection-readme.md"
30
- Dir.mkdir "doc" unless File.directory? "doc"
30
+ doc = File.read('README.md')
31
+ file = 'doc/rack-protection-readme.md'
32
+ Dir.mkdir 'doc' unless File.directory? 'doc'
31
33
  puts "writing #{file}"
32
- File.open(file, "w") { |f| f << doc }
34
+ File.open(file, 'w') { |f| f << doc }
33
35
  end
34
36
 
35
- task :all => [:readmes, :index]
37
+ task all: %i[readmes index]
36
38
  end
37
39
 
38
- desc "generate documentation"
39
- task :doc => 'doc:all'
40
+ desc 'generate documentation'
41
+ task doc: 'doc:all'
40
42
 
41
- desc "generate gemspec"
43
+ desc 'generate gemspec'
42
44
  task 'rack-protection.gemspec' do
43
45
  require 'rack/protection/version'
44
46
  content = File.binread 'rack-protection.gemspec'
45
47
 
46
48
  # fetch data
47
49
  fields = {
48
- :authors => `git shortlog -sn`.force_encoding('utf-8').scan(/[^\d\s].*/),
49
- :email => ["mail@zzak.io", "konstantin.haase@gmail.com"],
50
- :files => %w(License README.md Rakefile Gemfile rack-protection.gemspec) + Dir['lib/**/*']
50
+ authors: `git shortlog -sn`.force_encoding('utf-8').scan(/[^\d\s].*/),
51
+ email: ['mail@zzak.io', 'konstantin.haase@gmail.com'],
52
+ files: %w[License README.md Rakefile Gemfile rack-protection.gemspec] + Dir['lib/**/*']
51
53
  }
52
54
 
53
55
  # insert data
@@ -67,6 +69,6 @@ task 'rack-protection.gemspec' do
67
69
  File.open('rack-protection.gemspec', 'w') { |f| f << content }
68
70
  end
69
71
 
70
- task :gemspec => 'rack-protection.gemspec'
71
- task :default => :spec
72
- task :test => :spec
72
+ task gemspec: 'rack-protection.gemspec'
73
+ task default: :spec
74
+ task test: :spec
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/protection'
2
4
  require 'securerandom'
5
+ require 'openssl'
3
6
  require 'base64'
4
7
 
5
8
  module Rack
@@ -16,7 +19,10 @@ module Rack
16
19
  # It checks the <tt>X-CSRF-Token</tt> header and the <tt>POST</tt> form
17
20
  # data.
18
21
  #
19
- # Compatible with the {rack-csrf}[https://rubygems.org/gems/rack_csrf] gem.
22
+ # It is not OOTB-compatible with the {rack-csrf}[https://rubygems.org/gems/rack_csrf] gem.
23
+ # For that, the following patch needs to be applied:
24
+ #
25
+ # Rack::Protection::AuthenticityToken.default_options(key: "csrf.token", authenticity_param: "_csrf")
20
26
  #
21
27
  # == Options
22
28
  #
@@ -24,6 +30,13 @@ module Rack
24
30
  # the token on a request. Default value:
25
31
  # <tt>"authenticity_token"</tt>
26
32
  #
33
+ # [<tt>:key</tt>] the name of the param that should contain
34
+ # the token in the session. Default value:
35
+ # <tt>:csrf</tt>
36
+ #
37
+ # [<tt>:allow_if</tt>] a proc for custom allow/deny logic. Default value:
38
+ # <tt>nil</tt>
39
+ #
27
40
  # == Example: Forms application
28
41
  #
29
42
  # To show what the AuthenticityToken does, this section includes a sample
@@ -84,42 +97,57 @@ module Rack
84
97
  class AuthenticityToken < Base
85
98
  TOKEN_LENGTH = 32
86
99
 
87
- default_options :authenticity_param => 'authenticity_token',
88
- :allow_if => nil
100
+ default_options authenticity_param: 'authenticity_token',
101
+ key: :csrf,
102
+ allow_if: nil
89
103
 
90
- def self.token(session)
91
- self.new(nil).mask_authenticity_token(session)
104
+ def self.token(session, path: nil, method: :post)
105
+ new(nil).mask_authenticity_token(session, path: path, method: method)
92
106
  end
93
107
 
94
108
  def self.random_token
95
- SecureRandom.base64(TOKEN_LENGTH)
109
+ SecureRandom.urlsafe_base64(TOKEN_LENGTH, padding: false)
96
110
  end
97
111
 
98
112
  def accepts?(env)
99
- session = session env
113
+ session = session(env)
100
114
  set_token(session)
101
115
 
102
116
  safe?(env) ||
103
- valid_token?(session, env['HTTP_X_CSRF_TOKEN']) ||
104
- valid_token?(session, Request.new(env).params[options[:authenticity_param]]) ||
105
- ( options[:allow_if] && options[:allow_if].call(env) )
117
+ valid_token?(env, env['HTTP_X_CSRF_TOKEN']) ||
118
+ valid_token?(env, Request.new(env).params[options[:authenticity_param]]) ||
119
+ options[:allow_if]&.call(env)
120
+ rescue StandardError
121
+ false
106
122
  end
107
123
 
108
- def mask_authenticity_token(session)
109
- token = set_token(session)
124
+ def mask_authenticity_token(session, path: nil, method: :post)
125
+ set_token(session)
126
+
127
+ token = if path && method
128
+ per_form_token(session, path, method)
129
+ else
130
+ global_token(session)
131
+ end
132
+
110
133
  mask_token(token)
111
134
  end
112
135
 
136
+ GLOBAL_TOKEN_IDENTIFIER = '!real_csrf_token'
137
+ private_constant :GLOBAL_TOKEN_IDENTIFIER
138
+
113
139
  private
114
140
 
115
141
  def set_token(session)
116
- session[:csrf] ||= self.class.random_token
142
+ session[options[:key]] ||= self.class.random_token
117
143
  end
118
144
 
119
145
  # Checks the client's masked token to see if it matches the
120
146
  # session token.
121
- def valid_token?(session, token)
122
- return false if token.nil? || token.empty?
147
+ def valid_token?(env, token)
148
+ return false if token.nil? || !token.is_a?(String) || token.empty?
149
+
150
+ session = session(env)
123
151
 
124
152
  begin
125
153
  token = decode_token(token)
@@ -131,13 +159,13 @@ module Rack
131
159
  # to handle any unmasked tokens that we've issued without error.
132
160
 
133
161
  if unmasked_token?(token)
134
- compare_with_real_token token, session
135
-
162
+ compare_with_real_token(token, session)
136
163
  elsif masked_token?(token)
137
164
  token = unmask_token(token)
138
165
 
139
- compare_with_real_token token, session
140
-
166
+ compare_with_global_token(token, session) ||
167
+ compare_with_real_token(token, session) ||
168
+ compare_with_per_form_token(token, session, Request.new(env))
141
169
  else
142
170
  false # Token is malformed
143
171
  end
@@ -147,7 +175,6 @@ module Rack
147
175
  # on each request. The masking is used to mitigate SSL attacks
148
176
  # like BREACH.
149
177
  def mask_token(token)
150
- token = decode_token(token)
151
178
  one_time_pad = SecureRandom.random_bytes(token.length)
152
179
  encrypted_token = xor_byte_strings(one_time_pad, token)
153
180
  masked_token = one_time_pad + encrypted_token
@@ -160,7 +187,7 @@ module Rack
160
187
  # value and decrypt it
161
188
  token_length = masked_token.length / 2
162
189
  one_time_pad = masked_token[0...token_length]
163
- encrypted_token = masked_token[token_length..-1]
190
+ encrypted_token = masked_token[token_length..]
164
191
  xor_byte_strings(one_time_pad, encrypted_token)
165
192
  end
166
193
 
@@ -176,16 +203,41 @@ module Rack
176
203
  secure_compare(token, real_token(session))
177
204
  end
178
205
 
206
+ def compare_with_global_token(token, session)
207
+ secure_compare(token, global_token(session))
208
+ end
209
+
210
+ def compare_with_per_form_token(token, session, request)
211
+ secure_compare(token,
212
+ per_form_token(session, request.path.chomp('/'), request.request_method))
213
+ end
214
+
179
215
  def real_token(session)
180
- decode_token(session[:csrf])
216
+ decode_token(session[options[:key]])
217
+ end
218
+
219
+ def global_token(session)
220
+ token_hmac(session, GLOBAL_TOKEN_IDENTIFIER)
221
+ end
222
+
223
+ def per_form_token(session, path, method)
224
+ token_hmac(session, "#{path}##{method.downcase}")
181
225
  end
182
226
 
183
227
  def encode_token(token)
184
- Base64.strict_encode64(token)
228
+ Base64.urlsafe_encode64(token)
185
229
  end
186
230
 
187
231
  def decode_token(token)
188
- Base64.strict_decode64(token)
232
+ Base64.urlsafe_decode64(token)
233
+ end
234
+
235
+ def token_hmac(session, identifier)
236
+ OpenSSL::HMAC.digest(
237
+ OpenSSL::Digest.new('SHA256'),
238
+ real_token(session),
239
+ identifier
240
+ )
189
241
  end
190
242
 
191
243
  def xor_byte_strings(s1, s2)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/protection'
2
4
  require 'rack/utils'
3
5
  require 'digest'
@@ -8,12 +10,12 @@ module Rack
8
10
  module Protection
9
11
  class Base
10
12
  DEFAULT_OPTIONS = {
11
- :reaction => :default_reaction, :logging => true,
12
- :message => 'Forbidden', :encryptor => Digest::SHA1,
13
- :session_key => 'rack.session', :status => 403,
14
- :allow_empty_referrer => true,
15
- :report_key => "protection.failed",
16
- :html_types => %w[text/html application/xhtml text/xml application/xml]
13
+ reaction: :default_reaction, logging: true,
14
+ message: 'Forbidden', encryptor: Digest::SHA1,
15
+ session_key: 'rack.session', status: 403,
16
+ allow_empty_referrer: true,
17
+ report_key: 'protection.failed',
18
+ html_types: %w[text/html application/xhtml text/xml application/xml]
17
19
  }
18
20
 
19
21
  attr_reader :app, :options
@@ -31,7 +33,8 @@ module Rack
31
33
  end
32
34
 
33
35
  def initialize(app, options = {})
34
- @app, @options = app, default_options.merge(options)
36
+ @app = app
37
+ @options = default_options.merge(options)
35
38
  end
36
39
 
37
40
  def safe?(env)
@@ -52,24 +55,26 @@ module Rack
52
55
 
53
56
  def react(env)
54
57
  result = send(options[:reaction], env)
55
- result if Array === result and result.size == 3
58
+ result if (Array === result) && (result.size == 3)
56
59
  end
57
60
 
58
61
  def warn(env, message)
59
62
  return unless options[:logging]
63
+
60
64
  l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors'])
61
65
  l.warn(message)
62
66
  end
63
67
 
64
68
  def instrument(env)
65
- return unless i = options[:instrumenter]
69
+ return unless (i = options[:instrumenter])
70
+
66
71
  env['rack.protection.attack'] = self.class.name.split('::').last.downcase
67
72
  i.instrument('rack.protection', env)
68
73
  end
69
74
 
70
75
  def deny(env)
71
76
  warn env, "attack prevented by #{self.class}"
72
- [options[:status], {'Content-Type' => 'text/plain'}, [options[:message]]]
77
+ [options[:status], { 'Content-Type' => 'text/plain' }, [options[:message]]]
73
78
  end
74
79
 
75
80
  def report(env)
@@ -83,16 +88,24 @@ module Rack
83
88
 
84
89
  def session(env)
85
90
  return env[options[:session_key]] if session? env
86
- fail "you need to set up a session middleware *before* #{self.class}"
91
+
92
+ raise "you need to set up a session middleware *before* #{self.class}"
87
93
  end
88
94
 
89
95
  def drop_session(env)
90
- session(env).clear if session? env
96
+ return unless session? env
97
+
98
+ session(env).clear
99
+
100
+ return if ["1", "true"].include?(ENV["RACK_PROTECTION_SILENCE_DROP_SESSION_WARNING"])
101
+
102
+ warn env, "session dropped by #{self.class}"
91
103
  end
92
104
 
93
105
  def referrer(env)
94
106
  ref = env['HTTP_REFERER'].to_s
95
- return if !options[:allow_empty_referrer] and ref.empty?
107
+ return if !options[:allow_empty_referrer] && ref.empty?
108
+
96
109
  URI.parse(ref).host || Request.new(env).host
97
110
  rescue URI::InvalidURIError
98
111
  end
@@ -102,7 +115,7 @@ module Rack
102
115
  end
103
116
 
104
117
  def random_string(secure = defined? SecureRandom)
105
- secure ? SecureRandom.hex(16) : "%032x" % rand(2**128-1)
118
+ secure ? SecureRandom.hex(16) : '%032x' % rand((2**128) - 1)
106
119
  rescue NotImplementedError
107
120
  random_string false
108
121
  end
@@ -118,8 +131,9 @@ module Rack
118
131
  alias default_reaction deny
119
132
 
120
133
  def html?(headers)
121
- return false unless header = headers.detect { |k,v| k.downcase == 'content-type' }
122
- options[:html_types].include? header.last[/^\w+\/\w+/]
134
+ return false unless (header = headers.detect { |k, _v| k.downcase == 'content-type' })
135
+
136
+ options[:html_types].include? header.last[%r{^\w+/\w+}]
123
137
  end
124
138
  end
125
139
  end
@@ -1,4 +1,5 @@
1
- # -*- coding: utf-8 -*-
1
+ # frozen_string_literal: true
2
+
2
3
  require 'rack/protection'
3
4
 
4
5
  module Rack
@@ -38,16 +39,16 @@ module Rack
38
39
  class ContentSecurityPolicy < Base
39
40
  default_options default_src: "'self'", report_only: false
40
41
 
41
- DIRECTIVES = %i(base_uri child_src connect_src default_src
42
+ DIRECTIVES = %i[base_uri child_src connect_src default_src
42
43
  font_src form_action frame_ancestors frame_src
43
44
  img_src manifest_src media_src object_src
44
45
  plugin_types referrer reflected_xss report_to
45
46
  report_uri require_sri_for sandbox script_src
46
47
  style_src worker_src webrtc_src navigate_to
47
- prefetch_src).freeze
48
+ prefetch_src].freeze
48
49
 
49
- NO_ARG_DIRECTIVES = %i(block_all_mixed_content disown_opener
50
- upgrade_insecure_requests).freeze
50
+ NO_ARG_DIRECTIVES = %i[block_all_mixed_content disown_opener
51
+ upgrade_insecure_requests].freeze
51
52
 
52
53
  def csp_policy
53
54
  directives = []
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/protection'
2
4
  require 'pathname'
3
5
 
@@ -29,9 +31,8 @@ module Rack
29
31
  cookie_header = env['HTTP_COOKIE']
30
32
  cookies = Rack::Utils.parse_query(cookie_header, ';,') { |s| s }
31
33
  cookies.each do |k, v|
32
- if k == session_key && Array(v).size > 1
33
- bad_cookies << k
34
- elsif k != session_key && Rack::Utils.unescape(k) == session_key
34
+ if (k == session_key && Array(v).size > 1) ||
35
+ (k != session_key && Rack::Utils.unescape(k) == session_key)
35
36
  bad_cookies << k
36
37
  end
37
38
  end
@@ -40,6 +41,7 @@ module Rack
40
41
 
41
42
  def remove_bad_cookies(request, response)
42
43
  return if bad_cookies.empty?
44
+
43
45
  paths = cookie_paths(request.path)
44
46
  bad_cookies.each do |name|
45
47
  paths.each { |path| response.set_cookie name, empty_cookie(request.host, path) }
@@ -49,7 +51,7 @@ module Rack
49
51
  def redirect(env)
50
52
  request = Request.new(env)
51
53
  warn env, "attack prevented by #{self.class}"
52
- [302, {'Content-Type' => 'text/html', 'Location' => request.path}, []]
54
+ [302, { 'Content-Type' => 'text/html', 'Location' => request.path }, []]
53
55
  end
54
56
 
55
57
  def bad_cookies
@@ -64,7 +66,7 @@ module Rack
64
66
  end
65
67
 
66
68
  def empty_cookie(host, path)
67
- {:value => '', :domain => host, :path => path, :expires => Time.at(0)}
69
+ { value: '', domain: host, path: path, expires: Time.at(0) }
68
70
  end
69
71
 
70
72
  def session_key