sidekiq 6.0.6 → 6.1.3

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sidekiq might be problematic. Click here for more details.

Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +20 -0
  3. data/.github/workflows/ci.yml +41 -0
  4. data/5.0-Upgrade.md +1 -1
  5. data/Changes.md +48 -1
  6. data/Ent-Changes.md +54 -1
  7. data/Gemfile +1 -1
  8. data/Gemfile.lock +97 -112
  9. data/Pro-Changes.md +34 -3
  10. data/README.md +2 -6
  11. data/bin/sidekiq +26 -2
  12. data/lib/sidekiq.rb +5 -3
  13. data/lib/sidekiq/api.rb +16 -10
  14. data/lib/sidekiq/cli.rb +24 -8
  15. data/lib/sidekiq/client.rb +16 -11
  16. data/lib/sidekiq/extensions/action_mailer.rb +3 -2
  17. data/lib/sidekiq/extensions/active_record.rb +4 -3
  18. data/lib/sidekiq/extensions/class_methods.rb +5 -4
  19. data/lib/sidekiq/fetch.rb +20 -20
  20. data/lib/sidekiq/job_retry.rb +1 -0
  21. data/lib/sidekiq/launcher.rb +47 -13
  22. data/lib/sidekiq/logger.rb +3 -2
  23. data/lib/sidekiq/manager.rb +4 -4
  24. data/lib/sidekiq/middleware/chain.rb +1 -1
  25. data/lib/sidekiq/processor.rb +4 -4
  26. data/lib/sidekiq/rails.rb +16 -18
  27. data/lib/sidekiq/redis_connection.rb +18 -13
  28. data/lib/sidekiq/sd_notify.rb +1 -1
  29. data/lib/sidekiq/systemd.rb +1 -15
  30. data/lib/sidekiq/testing.rb +1 -1
  31. data/lib/sidekiq/version.rb +1 -1
  32. data/lib/sidekiq/web.rb +15 -7
  33. data/lib/sidekiq/web/application.rb +2 -4
  34. data/lib/sidekiq/web/csrf_protection.rb +156 -0
  35. data/lib/sidekiq/web/helpers.rb +15 -6
  36. data/lib/sidekiq/web/router.rb +1 -1
  37. data/lib/sidekiq/worker.rb +2 -5
  38. data/sidekiq.gemspec +2 -3
  39. data/web/assets/javascripts/application.js +3 -8
  40. data/web/assets/stylesheets/application-dark.css +60 -33
  41. data/web/assets/stylesheets/application.css +19 -6
  42. data/web/locales/fr.yml +3 -3
  43. data/web/locales/pl.yml +4 -4
  44. data/web/locales/ru.yml +4 -0
  45. data/web/locales/vi.yml +83 -0
  46. data/web/views/busy.erb +4 -2
  47. data/web/views/morgue.erb +1 -1
  48. data/web/views/queues.erb +1 -1
  49. data/web/views/retries.erb +1 -1
  50. data/web/views/scheduled.erb +1 -1
  51. metadata +13 -25
  52. data/.circleci/config.yml +0 -60
  53. data/.github/issue_template.md +0 -11
@@ -61,6 +61,7 @@ module Sidekiq
61
61
  #
62
62
  class JobRetry
63
63
  class Handled < ::RuntimeError; end
64
+
64
65
  class Skip < Handled; end
65
66
 
66
67
  include Sidekiq::Util
@@ -22,6 +22,7 @@ module Sidekiq
22
22
  attr_accessor :manager, :poller, :fetcher
23
23
 
24
24
  def initialize(options)
25
+ options[:fetch] ||= BasicFetch.new(options)
25
26
  @manager = Sidekiq::Manager.new(options)
26
27
  @poller = Sidekiq::Scheduled::Poller.new
27
28
  @done = false
@@ -56,7 +57,7 @@ module Sidekiq
56
57
 
57
58
  # Requeue everything in case there was a worker who grabbed work while stopped
58
59
  # This call is a no-op in Sidekiq but necessary for Sidekiq Pro.
59
- strategy = (@options[:fetch] || Sidekiq::BasicFetch)
60
+ strategy = @options[:fetch]
60
61
  strategy.bulk_requeue([], @options)
61
62
 
62
63
  clear_heartbeat
@@ -97,19 +98,27 @@ module Sidekiq
97
98
  end
98
99
 
99
100
  def self.flush_stats
100
- nowdate = Time.now.utc.strftime("%Y-%m-%d")
101
101
  fails = Processor::FAILURE.reset
102
102
  procd = Processor::PROCESSED.reset
103
- Sidekiq.redis do |conn|
104
- conn.pipelined do
105
- conn.incrby("stat:processed", procd)
106
- conn.incrby("stat:processed:#{nowdate}", procd)
107
- conn.expire("stat:processed:#{nowdate}", STATS_TTL)
103
+ return if fails + procd == 0
104
+
105
+ nowdate = Time.now.utc.strftime("%Y-%m-%d")
106
+ begin
107
+ Sidekiq.redis do |conn|
108
+ conn.pipelined do
109
+ conn.incrby("stat:processed", procd)
110
+ conn.incrby("stat:processed:#{nowdate}", procd)
111
+ conn.expire("stat:processed:#{nowdate}", STATS_TTL)
108
112
 
109
- conn.incrby("stat:failed", fails)
110
- conn.incrby("stat:failed:#{nowdate}", fails)
111
- conn.expire("stat:failed:#{nowdate}", STATS_TTL)
113
+ conn.incrby("stat:failed", fails)
114
+ conn.incrby("stat:failed:#{nowdate}", fails)
115
+ conn.expire("stat:failed:#{nowdate}", STATS_TTL)
116
+ end
112
117
  end
118
+ rescue => ex
119
+ # we're exiting the process, things might be shut down so don't
120
+ # try to handle the exception
121
+ Sidekiq.logger.warn("Unable to flush stats: #{ex}")
113
122
  end
114
123
  end
115
124
  at_exit(&method(:flush_stats))
@@ -145,12 +154,17 @@ module Sidekiq
145
154
  end
146
155
 
147
156
  fails = procd = 0
157
+ kb = memory_usage(::Process.pid)
148
158
 
149
159
  _, exists, _, _, msg = Sidekiq.redis { |conn|
150
160
  conn.multi {
151
161
  conn.sadd("processes", key)
152
- conn.exists(key)
153
- conn.hmset(key, "info", to_json, "busy", curstate.size, "beat", Time.now.to_f, "quiet", @done)
162
+ conn.exists?(key)
163
+ conn.hmset(key, "info", to_json,
164
+ "busy", curstate.size,
165
+ "beat", Time.now.to_f,
166
+ "quiet", @done,
167
+ "rss", kb)
154
168
  conn.expire(key, 60)
155
169
  conn.rpop("#{key}-signals")
156
170
  }
@@ -164,13 +178,33 @@ module Sidekiq
164
178
  ::Process.kill(msg, ::Process.pid)
165
179
  rescue => e
166
180
  # ignore all redis/network issues
167
- logger.error("heartbeat: #{e.message}")
181
+ logger.error("heartbeat: #{e}")
168
182
  # don't lose the counts if there was a network issue
169
183
  Processor::PROCESSED.incr(procd)
170
184
  Processor::FAILURE.incr(fails)
171
185
  end
172
186
  end
173
187
 
188
+ MEMORY_GRABBER = case RUBY_PLATFORM
189
+ when /linux/
190
+ ->(pid) {
191
+ IO.readlines("/proc/#{$$}/status").each do |line|
192
+ next unless line.start_with?("VmRSS:")
193
+ break line.split[1].to_i
194
+ end
195
+ }
196
+ when /darwin|bsd/
197
+ ->(pid) {
198
+ `ps -o pid,rss -p #{pid}`.lines.last.split.last.to_i
199
+ }
200
+ else
201
+ ->(pid) { 0 }
202
+ end
203
+
204
+ def memory_usage(pid)
205
+ MEMORY_GRABBER.call(pid)
206
+ end
207
+
174
208
  def to_data
175
209
  @data ||= begin
176
210
  {
@@ -6,10 +6,11 @@ require "time"
6
6
  module Sidekiq
7
7
  module Context
8
8
  def self.with(hash)
9
+ orig_context = current.dup
9
10
  current.merge!(hash)
10
11
  yield
11
12
  ensure
12
- hash.each_key { |key| current.delete(key) }
13
+ Thread.current[:sidekiq_context] = orig_context
13
14
  end
14
15
 
15
16
  def self.current
@@ -89,7 +90,7 @@ module Sidekiq
89
90
  return true if @logdev.nil? || severity < level
90
91
 
91
92
  if message.nil?
92
- if block_given?
93
+ if block
93
94
  message = yield
94
95
  else
95
96
  message = progname
@@ -35,7 +35,7 @@ module Sidekiq
35
35
  @done = false
36
36
  @workers = Set.new
37
37
  @count.times do
38
- @workers << Processor.new(self)
38
+ @workers << Processor.new(self, options)
39
39
  end
40
40
  @plock = Mutex.new
41
41
  end
@@ -56,7 +56,7 @@ module Sidekiq
56
56
  end
57
57
 
58
58
  # hack for quicker development / testing environment #2774
59
- PAUSE_TIME = STDOUT.tty? ? 0.1 : 0.5
59
+ PAUSE_TIME = $stdout.tty? ? 0.1 : 0.5
60
60
 
61
61
  def stop(deadline)
62
62
  quiet
@@ -90,7 +90,7 @@ module Sidekiq
90
90
  @plock.synchronize do
91
91
  @workers.delete(processor)
92
92
  unless @done
93
- p = Processor.new(self)
93
+ p = Processor.new(self, options)
94
94
  @workers << p
95
95
  p.start
96
96
  end
@@ -123,7 +123,7 @@ module Sidekiq
123
123
  # contract says that jobs are run AT LEAST once. Process termination
124
124
  # is delayed until we're certain the jobs are back in Redis because
125
125
  # it is worse to lose a job than to run it twice.
126
- strategy = (@options[:fetch] || Sidekiq::BasicFetch)
126
+ strategy = @options[:fetch]
127
127
  strategy.bulk_requeue(jobs, @options)
128
128
  end
129
129
 
@@ -133,7 +133,7 @@ module Sidekiq
133
133
  return yield if empty?
134
134
 
135
135
  chain = retrieve.dup
136
- traverse_chain = lambda do
136
+ traverse_chain = proc do
137
137
  if chain.empty?
138
138
  yield
139
139
  else
@@ -28,15 +28,15 @@ module Sidekiq
28
28
  attr_reader :thread
29
29
  attr_reader :job
30
30
 
31
- def initialize(mgr)
31
+ def initialize(mgr, options)
32
32
  @mgr = mgr
33
33
  @down = false
34
34
  @done = false
35
35
  @job = nil
36
36
  @thread = nil
37
- @strategy = (mgr.options[:fetch] || Sidekiq::BasicFetch).new(mgr.options)
38
- @reloader = Sidekiq.options[:reloader]
39
- @job_logger = (mgr.options[:job_logger] || Sidekiq::JobLogger).new
37
+ @strategy = options[:fetch]
38
+ @reloader = options[:reloader] || proc { |&block| block.call }
39
+ @job_logger = (options[:job_logger] || Sidekiq::JobLogger).new
40
40
  @retrier = Sidekiq::JobRetry.new
41
41
  end
42
42
 
@@ -4,6 +4,22 @@ require "sidekiq/worker"
4
4
 
5
5
  module Sidekiq
6
6
  class Rails < ::Rails::Engine
7
+ class Reloader
8
+ def initialize(app = ::Rails.application)
9
+ @app = app
10
+ end
11
+
12
+ def call
13
+ @app.reloader.wrap do
14
+ yield
15
+ end
16
+ end
17
+
18
+ def inspect
19
+ "#<Sidekiq::Rails::Reloader @app=#{@app.class.name}>"
20
+ end
21
+ end
22
+
7
23
  # By including the Options module, we allow AJs to directly control sidekiq features
8
24
  # via the *sidekiq_options* class method and, for instance, not use AJ's retry system.
9
25
  # AJ retries don't show up in the Sidekiq UI Retries tab, save any error data, can't be
@@ -23,8 +39,6 @@ module Sidekiq
23
39
 
24
40
  # This hook happens after all initializers are run, just before returning
25
41
  # from config/environment.rb back to sidekiq/cli.rb.
26
- # We have to add the reloader after initialize to see if cache_classes has
27
- # been turned on.
28
42
  #
29
43
  # None of this matters on the client-side, only within the Sidekiq process itself.
30
44
  config.after_initialize do
@@ -32,21 +46,5 @@ module Sidekiq
32
46
  Sidekiq.options[:reloader] = Sidekiq::Rails::Reloader.new
33
47
  end
34
48
  end
35
-
36
- class Reloader
37
- def initialize(app = ::Rails.application)
38
- @app = app
39
- end
40
-
41
- def call
42
- @app.reloader.wrap do
43
- yield
44
- end
45
- end
46
-
47
- def inspect
48
- "#<Sidekiq::Rails::Reloader @app=#{@app.class.name}>"
49
- end
50
- end
51
49
  end
52
50
  end
@@ -8,15 +8,14 @@ module Sidekiq
8
8
  class RedisConnection
9
9
  class << self
10
10
  def create(options = {})
11
- options.keys.each do |key|
12
- options[key.to_sym] = options.delete(key)
13
- end
11
+ symbolized_options = options.transform_keys(&:to_sym)
14
12
 
15
- options[:id] = "Sidekiq-#{Sidekiq.server? ? "server" : "client"}-PID-#{::Process.pid}" unless options.key?(:id)
16
- options[:url] ||= determine_redis_provider
13
+ if !symbolized_options[:url] && (u = determine_redis_provider)
14
+ symbolized_options[:url] = u
15
+ end
17
16
 
18
- size = if options[:size]
19
- options[:size]
17
+ size = if symbolized_options[:size]
18
+ symbolized_options[:size]
20
19
  elsif Sidekiq.server?
21
20
  # Give ourselves plenty of connections. pool is lazy
22
21
  # so we won't create them until we need them.
@@ -29,11 +28,11 @@ module Sidekiq
29
28
 
30
29
  verify_sizing(size, Sidekiq.options[:concurrency]) if Sidekiq.server?
31
30
 
32
- pool_timeout = options[:pool_timeout] || 1
33
- log_info(options)
31
+ pool_timeout = symbolized_options[:pool_timeout] || 1
32
+ log_info(symbolized_options)
34
33
 
35
34
  ConnectionPool.new(timeout: pool_timeout, size: size) do
36
- build_client(options)
35
+ build_client(symbolized_options)
37
36
  end
38
37
  end
39
38
 
@@ -93,9 +92,15 @@ module Sidekiq
93
92
  end
94
93
 
95
94
  def log_info(options)
96
- # Don't log Redis AUTH password
97
95
  redacted = "REDACTED"
98
- scrubbed_options = options.dup
96
+
97
+ # deep clone so we can muck with these options all we want
98
+ #
99
+ # exclude SSL params from dump-and-load because some information isn't
100
+ # safely dumpable in current Rubies
101
+ keys = options.keys
102
+ keys.delete(:ssl_params)
103
+ scrubbed_options = Marshal.load(Marshal.dump(options.slice(*keys)))
99
104
  if scrubbed_options[:url] && (uri = URI.parse(scrubbed_options[:url])) && uri.password
100
105
  uri.password = redacted
101
106
  scrubbed_options[:url] = uri.to_s
@@ -122,7 +127,7 @@ module Sidekiq
122
127
  # initialization code at all.
123
128
  #
124
129
  p = ENV["REDIS_PROVIDER"]
125
- if p && p =~ /\:/
130
+ if p && p =~ /:/
126
131
  raise <<~EOM
127
132
  REDIS_PROVIDER should be set to the name of the variable which contains the Redis URL, not a URL itself.
128
133
  Platforms like Heroku will sell addons that publish a *_URL variable. You need to tell Sidekiq with REDIS_PROVIDER, e.g.:
@@ -85,7 +85,7 @@ module Sidekiq
85
85
  notify(FDSTORE, unset_env)
86
86
  end
87
87
 
88
- # @param [Boolean] true if the service manager expects watchdog keep-alive
88
+ # @return [Boolean] true if the service manager expects watchdog keep-alive
89
89
  # notification messages to be sent from this process.
90
90
  #
91
91
  # If the $WATCHDOG_USEC environment variable is set,
@@ -16,23 +16,9 @@ module Sidekiq
16
16
  Sidekiq.logger.info "Pinging systemd watchdog every #{ping_f.round(1)} sec"
17
17
  Thread.new do
18
18
  loop do
19
- Sidekiq::SdNotify.watchdog
20
19
  sleep ping_f
20
+ Sidekiq::SdNotify.watchdog
21
21
  end
22
22
  end
23
23
  end
24
24
  end
25
-
26
- if ENV["NOTIFY_SOCKET"]
27
- Sidekiq.configure_server do |config|
28
- Sidekiq.logger.info "Enabling systemd notification integration"
29
- require "sidekiq/sd_notify"
30
- config.on(:startup) do
31
- Sidekiq::SdNotify.ready
32
- end
33
- config.on(:shutdown) do
34
- Sidekiq::SdNotify.stopping
35
- end
36
- Sidekiq.start_watchdog if Sidekiq::SdNotify.watchdog?
37
- end
38
- end
@@ -337,7 +337,7 @@ module Sidekiq
337
337
  Sidekiq::Extensions::DelayedModel.extend(TestingExtensions) if defined?(Sidekiq::Extensions::DelayedModel)
338
338
  end
339
339
 
340
- if defined?(::Rails) && Rails.respond_to?(:env) && !Rails.env.test?
340
+ if defined?(::Rails) && Rails.respond_to?(:env) && !Rails.env.test? && !$TESTING
341
341
  puts("**************************************************")
342
342
  puts("⛔️ WARNING: Sidekiq testing API enabled, but this is not the test environment. Your jobs will not go to Redis.")
343
343
  puts("**************************************************")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sidekiq
4
- VERSION = "6.0.6"
4
+ VERSION = "6.1.3"
5
5
  end
@@ -10,8 +10,9 @@ require "sidekiq/web/helpers"
10
10
  require "sidekiq/web/router"
11
11
  require "sidekiq/web/action"
12
12
  require "sidekiq/web/application"
13
+ require "sidekiq/web/csrf_protection"
13
14
 
14
- require "rack/protection"
15
+ require "rack/content_length"
15
16
 
16
17
  require "rack/builder"
17
18
  require "rack/file"
@@ -154,14 +155,14 @@ module Sidekiq
154
155
  def build_sessions
155
156
  middlewares = self.middlewares
156
157
 
157
- unless using?(::Rack::Protection) || ENV["RACK_ENV"] == "test"
158
- middlewares.unshift [[::Rack::Protection, {use: :authenticity_token}], nil]
159
- end
160
-
161
158
  s = sessions
162
- return unless s
163
159
 
164
- unless using? ::Rack::Session::Cookie
160
+ # turn on CSRF protection if sessions are enabled and this is not the test env
161
+ if s && !using?(CsrfProtection) && ENV["RACK_ENV"] != "test"
162
+ middlewares.unshift [[CsrfProtection], nil]
163
+ end
164
+
165
+ if s && !using?(::Rack::Session::Cookie)
165
166
  unless (secret = Web.session_secret)
166
167
  require "securerandom"
167
168
  secret = SecureRandom.hex(64)
@@ -172,6 +173,13 @@ module Sidekiq
172
173
 
173
174
  middlewares.unshift [[::Rack::Session::Cookie, options], nil]
174
175
  end
176
+
177
+ # Since Sidekiq::WebApplication no longer calculates its own
178
+ # Content-Length response header, we must ensure that the Rack middleware
179
+ # that does this is loaded
180
+ unless using? ::Rack::ContentLength
181
+ middlewares.unshift [[::Rack::ContentLength], nil]
182
+ end
175
183
  end
176
184
 
177
185
  def build
@@ -298,7 +298,7 @@ module Sidekiq
298
298
  self.class.run_afters(app, action)
299
299
  end
300
300
 
301
- resp = case resp
301
+ case resp
302
302
  when Array
303
303
  # redirects go here
304
304
  resp
@@ -313,12 +313,10 @@ module Sidekiq
313
313
  # we'll let Rack calculate Content-Length for us.
314
314
  [200, headers, [resp]]
315
315
  end
316
-
317
- resp
318
316
  end
319
317
 
320
318
  def self.helpers(mod = nil, &block)
321
- if block_given?
319
+ if block
322
320
  WebAction.class_eval(&block)
323
321
  else
324
322
  WebAction.send(:include, mod)
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ # this file originally based on authenticity_token.rb from the sinatra/rack-protection project
4
+ #
5
+ # The MIT License (MIT)
6
+ #
7
+ # Copyright (c) 2011-2017 Konstantin Haase
8
+ # Copyright (c) 2015-2017 Zachary Scott
9
+ #
10
+ # Permission is hereby granted, free of charge, to any person obtaining
11
+ # a copy of this software and associated documentation files (the
12
+ # 'Software'), to deal in the Software without restriction, including
13
+ # without limitation the rights to use, copy, modify, merge, publish,
14
+ # distribute, sublicense, and/or sell copies of the Software, and to
15
+ # permit persons to whom the Software is furnished to do so, subject to
16
+ # the following conditions:
17
+ #
18
+ # The above copyright notice and this permission notice shall be
19
+ # included in all copies or substantial portions of the Software.
20
+ #
21
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
22
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
23
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
25
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
26
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
27
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28
+
29
+ require "securerandom"
30
+ require "base64"
31
+ require "rack/request"
32
+
33
+ module Sidekiq
34
+ class Web
35
+ class CsrfProtection
36
+ def initialize(app, options = nil)
37
+ @app = app
38
+ end
39
+
40
+ def call(env)
41
+ accept?(env) ? admit(env) : deny(env)
42
+ end
43
+
44
+ private
45
+
46
+ def admit(env)
47
+ # On each successful request, we create a fresh masked token
48
+ # which will be used in any forms rendered for this request.
49
+ s = session(env)
50
+ s[:csrf] ||= SecureRandom.base64(TOKEN_LENGTH)
51
+ env[:csrf_token] = mask_token(s[:csrf])
52
+ @app.call(env)
53
+ end
54
+
55
+ def safe?(env)
56
+ %w[GET HEAD OPTIONS TRACE].include? env["REQUEST_METHOD"]
57
+ end
58
+
59
+ def logger(env)
60
+ @logger ||= (env["rack.logger"] || ::Logger.new(env["rack.errors"]))
61
+ end
62
+
63
+ def deny(env)
64
+ logger(env).warn "attack prevented by #{self.class}"
65
+ [403, {"Content-Type" => "text/plain"}, ["Forbidden"]]
66
+ end
67
+
68
+ def session(env)
69
+ env["rack.session"] || fail("you need to set up a session middleware *before* #{self.class}")
70
+ end
71
+
72
+ def accept?(env)
73
+ return true if safe?(env)
74
+
75
+ giventoken = ::Rack::Request.new(env).params["authenticity_token"]
76
+ valid_token?(env, giventoken)
77
+ end
78
+
79
+ TOKEN_LENGTH = 32
80
+
81
+ # Checks that the token given to us as a parameter matches
82
+ # the token stored in the session.
83
+ def valid_token?(env, giventoken)
84
+ return false if giventoken.nil? || giventoken.empty?
85
+
86
+ begin
87
+ token = decode_token(giventoken)
88
+ rescue ArgumentError # client input is invalid
89
+ return false
90
+ end
91
+
92
+ sess = session(env)
93
+ localtoken = sess[:csrf]
94
+
95
+ # Checks that Rack::Session::Cookie actualy contains the csrf toekn
96
+ return false if localtoken.nil?
97
+
98
+ # Rotate the session token after every use
99
+ sess[:csrf] = SecureRandom.base64(TOKEN_LENGTH)
100
+
101
+ # See if it's actually a masked token or not. We should be able
102
+ # to handle any unmasked tokens that we've issued without error.
103
+
104
+ if unmasked_token?(token)
105
+ compare_with_real_token token, localtoken
106
+ elsif masked_token?(token)
107
+ unmasked = unmask_token(token)
108
+ compare_with_real_token unmasked, localtoken
109
+ else
110
+ false # Token is malformed
111
+ end
112
+ end
113
+
114
+ # Creates a masked version of the authenticity token that varies
115
+ # on each request. The masking is used to mitigate SSL attacks
116
+ # like BREACH.
117
+ def mask_token(token)
118
+ token = decode_token(token)
119
+ one_time_pad = SecureRandom.random_bytes(token.length)
120
+ encrypted_token = xor_byte_strings(one_time_pad, token)
121
+ masked_token = one_time_pad + encrypted_token
122
+ Base64.strict_encode64(masked_token)
123
+ end
124
+
125
+ # Essentially the inverse of +mask_token+.
126
+ def unmask_token(masked_token)
127
+ # Split the token into the one-time pad and the encrypted
128
+ # value and decrypt it
129
+ token_length = masked_token.length / 2
130
+ one_time_pad = masked_token[0...token_length]
131
+ encrypted_token = masked_token[token_length..-1]
132
+ xor_byte_strings(one_time_pad, encrypted_token)
133
+ end
134
+
135
+ def unmasked_token?(token)
136
+ token.length == TOKEN_LENGTH
137
+ end
138
+
139
+ def masked_token?(token)
140
+ token.length == TOKEN_LENGTH * 2
141
+ end
142
+
143
+ def compare_with_real_token(token, local)
144
+ ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
145
+ end
146
+
147
+ def decode_token(token)
148
+ Base64.strict_decode64(token)
149
+ end
150
+
151
+ def xor_byte_strings(s1, s2)
152
+ s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*")
153
+ end
154
+ end
155
+ end
156
+ end