rack-protection 2.1.0 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
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