sidekiq 5.2.8 → 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 (90) 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/.gitignore +0 -2
  5. data/.standard.yml +20 -0
  6. data/5.0-Upgrade.md +1 -1
  7. data/6.0-Upgrade.md +72 -0
  8. data/Changes.md +196 -0
  9. data/Ent-2.0-Upgrade.md +37 -0
  10. data/Ent-Changes.md +72 -1
  11. data/Gemfile +12 -11
  12. data/Gemfile.lock +193 -0
  13. data/Pro-5.0-Upgrade.md +25 -0
  14. data/Pro-Changes.md +56 -2
  15. data/README.md +18 -34
  16. data/Rakefile +5 -4
  17. data/bin/sidekiq +26 -2
  18. data/bin/sidekiqload +32 -24
  19. data/bin/sidekiqmon +8 -0
  20. data/lib/generators/sidekiq/templates/worker_test.rb.erb +1 -1
  21. data/lib/generators/sidekiq/worker_generator.rb +21 -13
  22. data/lib/sidekiq/api.rb +245 -219
  23. data/lib/sidekiq/cli.rb +144 -180
  24. data/lib/sidekiq/client.rb +68 -48
  25. data/lib/sidekiq/delay.rb +5 -6
  26. data/lib/sidekiq/exception_handler.rb +10 -12
  27. data/lib/sidekiq/extensions/action_mailer.rb +13 -22
  28. data/lib/sidekiq/extensions/active_record.rb +13 -10
  29. data/lib/sidekiq/extensions/class_methods.rb +14 -11
  30. data/lib/sidekiq/extensions/generic_proxy.rb +4 -4
  31. data/lib/sidekiq/fetch.rb +29 -30
  32. data/lib/sidekiq/job_logger.rb +45 -7
  33. data/lib/sidekiq/job_retry.rb +62 -61
  34. data/lib/sidekiq/launcher.rb +112 -54
  35. data/lib/sidekiq/logger.rb +166 -0
  36. data/lib/sidekiq/manager.rb +11 -13
  37. data/lib/sidekiq/middleware/chain.rb +15 -5
  38. data/lib/sidekiq/middleware/i18n.rb +5 -7
  39. data/lib/sidekiq/monitor.rb +133 -0
  40. data/lib/sidekiq/paginator.rb +18 -14
  41. data/lib/sidekiq/processor.rb +71 -70
  42. data/lib/sidekiq/rails.rb +29 -37
  43. data/lib/sidekiq/redis_connection.rb +50 -48
  44. data/lib/sidekiq/scheduled.rb +28 -29
  45. data/lib/sidekiq/sd_notify.rb +149 -0
  46. data/lib/sidekiq/systemd.rb +24 -0
  47. data/lib/sidekiq/testing/inline.rb +2 -1
  48. data/lib/sidekiq/testing.rb +35 -24
  49. data/lib/sidekiq/util.rb +17 -16
  50. data/lib/sidekiq/version.rb +2 -1
  51. data/lib/sidekiq/web/action.rb +14 -10
  52. data/lib/sidekiq/web/application.rb +74 -72
  53. data/lib/sidekiq/web/csrf_protection.rb +156 -0
  54. data/lib/sidekiq/web/helpers.rb +97 -77
  55. data/lib/sidekiq/web/router.rb +18 -17
  56. data/lib/sidekiq/web.rb +53 -53
  57. data/lib/sidekiq/worker.rb +126 -102
  58. data/lib/sidekiq.rb +69 -44
  59. data/sidekiq.gemspec +15 -16
  60. data/web/assets/javascripts/application.js +25 -27
  61. data/web/assets/javascripts/dashboard.js +4 -23
  62. data/web/assets/stylesheets/application-dark.css +149 -0
  63. data/web/assets/stylesheets/application.css +28 -6
  64. data/web/locales/de.yml +14 -2
  65. data/web/locales/en.yml +2 -0
  66. data/web/locales/fr.yml +3 -3
  67. data/web/locales/ja.yml +4 -1
  68. data/web/locales/lt.yml +83 -0
  69. data/web/locales/pl.yml +4 -4
  70. data/web/locales/ru.yml +4 -0
  71. data/web/locales/vi.yml +83 -0
  72. data/web/views/_job_info.erb +2 -1
  73. data/web/views/busy.erb +8 -3
  74. data/web/views/dead.erb +2 -2
  75. data/web/views/layout.erb +1 -0
  76. data/web/views/morgue.erb +5 -2
  77. data/web/views/queue.erb +10 -1
  78. data/web/views/queues.erb +9 -1
  79. data/web/views/retries.erb +5 -2
  80. data/web/views/retry.erb +2 -2
  81. data/web/views/scheduled.erb +5 -2
  82. metadata +31 -49
  83. data/.circleci/config.yml +0 -61
  84. data/.github/issue_template.md +0 -11
  85. data/.travis.yml +0 -11
  86. data/bin/sidekiqctl +0 -20
  87. data/lib/sidekiq/core_ext.rb +0 -1
  88. data/lib/sidekiq/ctl.rb +0 -221
  89. data/lib/sidekiq/logging.rb +0 -122
  90. data/lib/sidekiq/middleware/server/active_record.rb +0 -23
data/lib/sidekiq/util.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
- require 'socket'
3
- require 'securerandom'
4
- require 'sidekiq/exception_handler'
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+ require "sidekiq/exception_handler"
5
6
 
6
7
  module Sidekiq
7
8
  ##
@@ -10,18 +11,16 @@ module Sidekiq
10
11
  module Util
11
12
  include ExceptionHandler
12
13
 
13
- EXPIRY = 60 * 60 * 24
14
-
15
14
  def watchdog(last_words)
16
15
  yield
17
16
  rescue Exception => ex
18
- handle_exception(ex, { context: last_words })
17
+ handle_exception(ex, {context: last_words})
19
18
  raise ex
20
19
  end
21
20
 
22
21
  def safe_thread(name, &block)
23
22
  Thread.new do
24
- Thread.current['sidekiq_label'] = name
23
+ Thread.current.name = name
25
24
  watchdog(name, &block)
26
25
  end
27
26
  end
@@ -34,8 +33,12 @@ module Sidekiq
34
33
  Sidekiq.redis(&block)
35
34
  end
36
35
 
36
+ def tid
37
+ Thread.current["sidekiq_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
38
+ end
39
+
37
40
  def hostname
38
- ENV['DYNO'] || Socket.gethostname
41
+ ENV["DYNO"] || Socket.gethostname
39
42
  end
40
43
 
41
44
  def process_nonce
@@ -43,22 +46,20 @@ module Sidekiq
43
46
  end
44
47
 
45
48
  def identity
46
- @@identity ||= "#{hostname}:#{$$}:#{process_nonce}"
49
+ @@identity ||= "#{hostname}:#{::Process.pid}:#{process_nonce}"
47
50
  end
48
51
 
49
- def fire_event(event, options={})
52
+ def fire_event(event, options = {})
50
53
  reverse = options[:reverse]
51
54
  reraise = options[:reraise]
52
55
 
53
56
  arr = Sidekiq.options[:lifecycle_events][event]
54
57
  arr.reverse! if reverse
55
58
  arr.each do |block|
56
- begin
57
- block.call
58
- rescue => ex
59
- handle_exception(ex, { context: "Exception during Sidekiq lifecycle event.", event: event })
60
- raise ex if reraise
61
- end
59
+ block.call
60
+ rescue => ex
61
+ handle_exception(ex, {context: "Exception during Sidekiq lifecycle event.", event: event})
62
+ raise ex if reraise
62
63
  end
63
64
  arr.clear
64
65
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Sidekiq
3
- VERSION = "5.2.8"
4
+ VERSION = "6.1.3"
4
5
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Sidekiq
4
4
  class WebAction
5
- RACK_SESSION = 'rack.session'
5
+ RACK_SESSION = "rack.session"
6
6
 
7
7
  attr_accessor :env, :block, :type
8
8
 
@@ -19,14 +19,14 @@ module Sidekiq
19
19
  end
20
20
 
21
21
  def redirect(location)
22
- throw :halt, [302, { "Location" => "#{request.base_url}#{location}" }, []]
22
+ throw :halt, [302, {"Location" => "#{request.base_url}#{location}"}, []]
23
23
  end
24
24
 
25
25
  def params
26
- indifferent_hash = Hash.new {|hash,key| hash[key.to_s] if Symbol === key }
26
+ indifferent_hash = Hash.new { |hash, key| hash[key.to_s] if Symbol === key }
27
27
 
28
28
  indifferent_hash.merge! request.params
29
- route_params.each {|k,v| indifferent_hash[k.to_s] = v }
29
+ route_params.each { |k, v| indifferent_hash[k.to_s] = v }
30
30
 
31
31
  indifferent_hash
32
32
  end
@@ -40,10 +40,14 @@ module Sidekiq
40
40
  end
41
41
 
42
42
  def erb(content, options = {})
43
- if content.kind_of? Symbol
43
+ if content.is_a? Symbol
44
44
  unless respond_to?(:"_erb_#{content}")
45
45
  src = ERB.new(File.read("#{Web.settings.views}/#{content}.erb")).src
46
- WebAction.class_eval("def _erb_#{content}\n#{src}\n end")
46
+ WebAction.class_eval <<-RUBY, __FILE__, __LINE__ + 1
47
+ def _erb_#{content}
48
+ #{src}
49
+ end
50
+ RUBY
47
51
  end
48
52
  end
49
53
 
@@ -64,22 +68,22 @@ module Sidekiq
64
68
  end
65
69
 
66
70
  def json(payload)
67
- [200, { "Content-Type" => "application/json", "Cache-Control" => "no-cache" }, [Sidekiq.dump_json(payload)]]
71
+ [200, {"Content-Type" => "application/json", "Cache-Control" => "no-cache"}, [Sidekiq.dump_json(payload)]]
68
72
  end
69
73
 
70
74
  def initialize(env, block)
71
75
  @_erb = false
72
76
  @env = env
73
77
  @block = block
74
- @@files ||= {}
78
+ @files ||= {}
75
79
  end
76
80
 
77
81
  private
78
82
 
79
83
  def _erb(file, locals)
80
- locals.each {|k, v| define_singleton_method(k){ v } unless (singleton_methods.include? k)} if locals
84
+ locals&.each { |k, v| define_singleton_method(k) { v } unless singleton_methods.include? k }
81
85
 
82
- if file.kind_of?(String)
86
+ if file.is_a?(String)
83
87
  ERB.new(file).result(binding)
84
88
  else
85
89
  send(:"_erb_#{file}")
@@ -5,8 +5,7 @@ module Sidekiq
5
5
  extend WebRouter
6
6
 
7
7
  CONTENT_LENGTH = "Content-Length"
8
- CONTENT_TYPE = "Content-Type"
9
- REDIS_KEYS = %w(redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human)
8
+ REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human]
10
9
  CSP_HEADER = [
11
10
  "default-src 'self' https: http:",
12
11
  "child-src 'self'",
@@ -21,7 +20,7 @@ module Sidekiq
21
20
  "style-src 'self' https: http: 'unsafe-inline'",
22
21
  "worker-src 'self'",
23
22
  "base-uri 'self'"
24
- ].join('; ').freeze
23
+ ].join("; ").freeze
25
24
 
26
25
  def initialize(klass)
27
26
  @klass = klass
@@ -44,8 +43,8 @@ module Sidekiq
44
43
  end
45
44
 
46
45
  get "/" do
47
- @redis_info = redis_info.select{ |k, v| REDIS_KEYS.include? k }
48
- stats_history = Sidekiq::Stats::History.new((params['days'] || 30).to_i)
46
+ @redis_info = redis_info.select { |k, v| REDIS_KEYS.include? k }
47
+ stats_history = Sidekiq::Stats::History.new((params["days"] || 30).to_i)
49
48
  @processed_history = stats_history.processed
50
49
  @failed_history = stats_history.failed
51
50
 
@@ -57,14 +56,14 @@ module Sidekiq
57
56
  end
58
57
 
59
58
  post "/busy" do
60
- if params['identity']
61
- p = Sidekiq::Process.new('identity' => params['identity'])
62
- p.quiet! if params['quiet']
63
- p.stop! if params['stop']
59
+ if params["identity"]
60
+ p = Sidekiq::Process.new("identity" => params["identity"])
61
+ p.quiet! if params["quiet"]
62
+ p.stop! if params["stop"]
64
63
  else
65
64
  processes.each do |pro|
66
- pro.quiet! if params['quiet']
67
- pro.stop! if params['stop']
65
+ pro.quiet! if params["quiet"]
66
+ pro.stop! if params["stop"]
68
67
  end
69
68
  end
70
69
 
@@ -82,37 +81,46 @@ module Sidekiq
82
81
 
83
82
  halt(404) unless @name
84
83
 
85
- @count = (params['count'] || 25).to_i
84
+ @count = (params["count"] || 25).to_i
86
85
  @queue = Sidekiq::Queue.new(@name)
87
- (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count)
86
+ (@current_page, @total_size, @messages) = page("queue:#{@name}", params["page"], @count, reverse: params["direction"] == "asc")
88
87
  @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) }
89
88
 
90
89
  erb(:queue)
91
90
  end
92
91
 
93
92
  post "/queues/:name" do
94
- Sidekiq::Queue.new(route_params[:name]).clear
93
+ queue = Sidekiq::Queue.new(route_params[:name])
94
+
95
+ if Sidekiq.pro? && params["pause"]
96
+ queue.pause!
97
+ elsif Sidekiq.pro? && params["unpause"]
98
+ queue.unpause!
99
+ else
100
+ queue.clear
101
+ end
95
102
 
96
103
  redirect "#{root_path}queues"
97
104
  end
98
105
 
99
106
  post "/queues/:name/delete" do
100
107
  name = route_params[:name]
101
- Sidekiq::Job.new(params['key_val'], name).delete
108
+ Sidekiq::Job.new(params["key_val"], name).delete
102
109
 
103
110
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
104
111
  end
105
112
 
106
- get '/morgue' do
107
- @count = (params['count'] || 25).to_i
108
- (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true)
113
+ get "/morgue" do
114
+ @count = (params["count"] || 25).to_i
115
+ (@current_page, @total_size, @dead) = page("dead", params["page"], @count, reverse: true)
109
116
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
110
117
 
111
118
  erb(:morgue)
112
119
  end
113
120
 
114
121
  get "/morgue/:key" do
115
- halt(404) unless key = route_params[:key]
122
+ key = route_params[:key]
123
+ halt(404) unless key
116
124
 
117
125
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
118
126
 
@@ -123,10 +131,10 @@ module Sidekiq
123
131
  end
124
132
  end
125
133
 
126
- post '/morgue' do
127
- redirect(request.path) unless params['key']
134
+ post "/morgue" do
135
+ redirect(request.path) unless params["key"]
128
136
 
129
- params['key'].each do |key|
137
+ params["key"].each do |key|
130
138
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
131
139
  retry_or_delete_or_kill job, params if job
132
140
  end
@@ -147,7 +155,8 @@ module Sidekiq
147
155
  end
148
156
 
149
157
  post "/morgue/:key" do
150
- halt(404) unless key = route_params[:key]
158
+ key = route_params[:key]
159
+ halt(404) unless key
151
160
 
152
161
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
153
162
  retry_or_delete_or_kill job, params if job
@@ -155,9 +164,9 @@ module Sidekiq
155
164
  redirect_with_query("#{root_path}morgue")
156
165
  end
157
166
 
158
- get '/retries' do
159
- @count = (params['count'] || 25).to_i
160
- (@current_page, @total_size, @retries) = page("retry", params['page'], @count)
167
+ get "/retries" do
168
+ @count = (params["count"] || 25).to_i
169
+ (@current_page, @total_size, @retries) = page("retry", params["page"], @count)
161
170
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
162
171
 
163
172
  erb(:retries)
@@ -173,10 +182,10 @@ module Sidekiq
173
182
  end
174
183
  end
175
184
 
176
- post '/retries' do
177
- redirect(request.path) unless params['key']
185
+ post "/retries" do
186
+ redirect(request.path) unless params["key"]
178
187
 
179
- params['key'].each do |key|
188
+ params["key"].each do |key|
180
189
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
181
190
  retry_or_delete_or_kill job, params if job
182
191
  end
@@ -210,9 +219,9 @@ module Sidekiq
210
219
  redirect_with_query("#{root_path}retries")
211
220
  end
212
221
 
213
- get '/scheduled' do
214
- @count = (params['count'] || 25).to_i
215
- (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count)
222
+ get "/scheduled" do
223
+ @count = (params["count"] || 25).to_i
224
+ (@current_page, @total_size, @scheduled) = page("schedule", params["page"], @count)
216
225
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
217
226
 
218
227
  erb(:scheduled)
@@ -228,10 +237,10 @@ module Sidekiq
228
237
  end
229
238
  end
230
239
 
231
- post '/scheduled' do
232
- redirect(request.path) unless params['key']
240
+ post "/scheduled" do
241
+ redirect(request.path) unless params["key"]
233
242
 
234
- params['key'].each do |key|
243
+ params["key"].each do |key|
235
244
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
236
245
  delete_or_add_queue job, params if job
237
246
  end
@@ -240,7 +249,8 @@ module Sidekiq
240
249
  end
241
250
 
242
251
  post "/scheduled/:key" do
243
- halt(404) unless key = route_params[:key]
252
+ key = route_params[:key]
253
+ halt(404) unless key
244
254
 
245
255
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
246
256
  delete_or_add_queue job, params if job
@@ -248,23 +258,23 @@ module Sidekiq
248
258
  redirect_with_query("#{root_path}scheduled")
249
259
  end
250
260
 
251
- get '/dashboard/stats' do
261
+ get "/dashboard/stats" do
252
262
  redirect "#{root_path}stats"
253
263
  end
254
264
 
255
- get '/stats' do
265
+ get "/stats" do
256
266
  sidekiq_stats = Sidekiq::Stats.new
257
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
267
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
258
268
  json(
259
269
  sidekiq: {
260
- processed: sidekiq_stats.processed,
261
- failed: sidekiq_stats.failed,
262
- busy: sidekiq_stats.workers_size,
263
- processes: sidekiq_stats.processes_size,
264
- enqueued: sidekiq_stats.enqueued,
265
- scheduled: sidekiq_stats.scheduled_size,
266
- retries: sidekiq_stats.retry_size,
267
- dead: sidekiq_stats.dead_size,
270
+ processed: sidekiq_stats.processed,
271
+ failed: sidekiq_stats.failed,
272
+ busy: sidekiq_stats.workers_size,
273
+ processes: sidekiq_stats.processes_size,
274
+ enqueued: sidekiq_stats.enqueued,
275
+ scheduled: sidekiq_stats.scheduled_size,
276
+ retries: sidekiq_stats.retry_size,
277
+ dead: sidekiq_stats.dead_size,
268
278
  default_latency: sidekiq_stats.default_queue_latency
269
279
  },
270
280
  redis: redis_stats,
@@ -272,60 +282,52 @@ module Sidekiq
272
282
  )
273
283
  end
274
284
 
275
- get '/stats/queues' do
285
+ get "/stats/queues" do
276
286
  json Sidekiq::Stats::Queues.new.lengths
277
287
  end
278
288
 
279
289
  def call(env)
280
290
  action = self.class.match(env)
281
- return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
291
+ return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass"}, ["Not Found"]] unless action
282
292
 
283
- resp = catch(:halt) do
284
- app = @klass
293
+ app = @klass
294
+ resp = catch(:halt) do # rubocop:disable Standard/SemanticBlocks
285
295
  self.class.run_befores(app, action)
286
- begin
287
- resp = action.instance_exec env, &action.block
288
- ensure
289
- self.class.run_afters(app, action)
290
- end
291
-
292
- resp
296
+ action.instance_exec env, &action.block
297
+ ensure
298
+ self.class.run_afters(app, action)
293
299
  end
294
300
 
295
- resp = case resp
301
+ case resp
296
302
  when Array
303
+ # redirects go here
297
304
  resp
298
305
  else
306
+ # rendered content goes here
299
307
  headers = {
300
308
  "Content-Type" => "text/html",
301
309
  "Cache-Control" => "no-cache",
302
310
  "Content-Language" => action.locale,
303
311
  "Content-Security-Policy" => CSP_HEADER
304
312
  }
305
-
313
+ # we'll let Rack calculate Content-Length for us.
306
314
  [200, headers, [resp]]
307
315
  end
308
-
309
- resp[1] = resp[1].dup
310
-
311
- resp[1][CONTENT_LENGTH] = resp[2].inject(0) { |l, p| l + p.bytesize }.to_s
312
-
313
- resp
314
316
  end
315
317
 
316
- def self.helpers(mod=nil, &block)
317
- if block_given?
318
+ def self.helpers(mod = nil, &block)
319
+ if block
318
320
  WebAction.class_eval(&block)
319
321
  else
320
322
  WebAction.send(:include, mod)
321
323
  end
322
324
  end
323
325
 
324
- def self.before(path=nil, &block)
326
+ def self.before(path = nil, &block)
325
327
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
326
328
  end
327
329
 
328
- def self.after(path=nil, &block)
330
+ def self.after(path = nil, &block)
329
331
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
330
332
  end
331
333
 
@@ -338,8 +340,8 @@ module Sidekiq
338
340
  end
339
341
 
340
342
  def self.run_hooks(hooks, app, action)
341
- hooks.select { |p,_| !p || p =~ action.env[WebRouter::PATH_INFO] }.
342
- each {|_,b| action.instance_exec(action.env, app, &b) }
343
+ hooks.select { |p, _| !p || p =~ action.env[WebRouter::PATH_INFO] }
344
+ .each { |_, b| action.instance_exec(action.env, app, &b) }
343
345
  end
344
346
 
345
347
  def self.befores
@@ -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