sidekiq 4.2.10 → 6.1.2

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 (106) hide show
  1. checksums.yaml +5 -5
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +20 -0
  3. data/.github/workflows/ci.yml +41 -0
  4. data/.gitignore +2 -1
  5. data/.standard.yml +20 -0
  6. data/5.0-Upgrade.md +56 -0
  7. data/6.0-Upgrade.md +72 -0
  8. data/COMM-LICENSE +12 -10
  9. data/Changes.md +354 -1
  10. data/Ent-2.0-Upgrade.md +37 -0
  11. data/Ent-Changes.md +111 -3
  12. data/Gemfile +16 -21
  13. data/Gemfile.lock +192 -0
  14. data/LICENSE +1 -1
  15. data/Pro-4.0-Upgrade.md +35 -0
  16. data/Pro-5.0-Upgrade.md +25 -0
  17. data/Pro-Changes.md +181 -4
  18. data/README.md +19 -33
  19. data/Rakefile +6 -8
  20. data/bin/sidekiq +26 -2
  21. data/bin/sidekiqload +37 -34
  22. data/bin/sidekiqmon +8 -0
  23. data/lib/generators/sidekiq/templates/worker_spec.rb.erb +1 -1
  24. data/lib/generators/sidekiq/templates/worker_test.rb.erb +1 -1
  25. data/lib/generators/sidekiq/worker_generator.rb +21 -13
  26. data/lib/sidekiq.rb +86 -61
  27. data/lib/sidekiq/api.rb +320 -209
  28. data/lib/sidekiq/cli.rb +207 -217
  29. data/lib/sidekiq/client.rb +78 -51
  30. data/lib/sidekiq/delay.rb +41 -0
  31. data/lib/sidekiq/exception_handler.rb +12 -16
  32. data/lib/sidekiq/extensions/action_mailer.rb +13 -22
  33. data/lib/sidekiq/extensions/active_record.rb +13 -10
  34. data/lib/sidekiq/extensions/class_methods.rb +14 -11
  35. data/lib/sidekiq/extensions/generic_proxy.rb +10 -4
  36. data/lib/sidekiq/fetch.rb +29 -30
  37. data/lib/sidekiq/job_logger.rb +63 -0
  38. data/lib/sidekiq/job_retry.rb +262 -0
  39. data/lib/sidekiq/launcher.rb +102 -69
  40. data/lib/sidekiq/logger.rb +165 -0
  41. data/lib/sidekiq/manager.rb +16 -19
  42. data/lib/sidekiq/middleware/chain.rb +15 -5
  43. data/lib/sidekiq/middleware/i18n.rb +5 -7
  44. data/lib/sidekiq/monitor.rb +133 -0
  45. data/lib/sidekiq/paginator.rb +18 -14
  46. data/lib/sidekiq/processor.rb +161 -82
  47. data/lib/sidekiq/rails.rb +27 -100
  48. data/lib/sidekiq/redis_connection.rb +60 -20
  49. data/lib/sidekiq/scheduled.rb +61 -35
  50. data/lib/sidekiq/sd_notify.rb +149 -0
  51. data/lib/sidekiq/systemd.rb +24 -0
  52. data/lib/sidekiq/testing.rb +48 -28
  53. data/lib/sidekiq/testing/inline.rb +2 -1
  54. data/lib/sidekiq/util.rb +20 -16
  55. data/lib/sidekiq/version.rb +2 -1
  56. data/lib/sidekiq/web.rb +57 -57
  57. data/lib/sidekiq/web/action.rb +14 -14
  58. data/lib/sidekiq/web/application.rb +103 -84
  59. data/lib/sidekiq/web/csrf_protection.rb +158 -0
  60. data/lib/sidekiq/web/helpers.rb +126 -71
  61. data/lib/sidekiq/web/router.rb +18 -17
  62. data/lib/sidekiq/worker.rb +164 -41
  63. data/sidekiq.gemspec +15 -27
  64. data/web/assets/javascripts/application.js +25 -27
  65. data/web/assets/javascripts/dashboard.js +33 -37
  66. data/web/assets/stylesheets/application-dark.css +143 -0
  67. data/web/assets/stylesheets/application-rtl.css +246 -0
  68. data/web/assets/stylesheets/application.css +385 -10
  69. data/web/assets/stylesheets/bootstrap-rtl.min.css +9 -0
  70. data/web/assets/stylesheets/bootstrap.css +2 -2
  71. data/web/locales/ar.yml +81 -0
  72. data/web/locales/de.yml +14 -2
  73. data/web/locales/en.yml +4 -0
  74. data/web/locales/es.yml +4 -3
  75. data/web/locales/fa.yml +1 -0
  76. data/web/locales/fr.yml +2 -2
  77. data/web/locales/he.yml +79 -0
  78. data/web/locales/ja.yml +9 -4
  79. data/web/locales/lt.yml +83 -0
  80. data/web/locales/pl.yml +4 -4
  81. data/web/locales/ru.yml +4 -0
  82. data/web/locales/ur.yml +80 -0
  83. data/web/locales/vi.yml +83 -0
  84. data/web/views/_footer.erb +5 -2
  85. data/web/views/_job_info.erb +2 -1
  86. data/web/views/_nav.erb +4 -18
  87. data/web/views/_paging.erb +1 -1
  88. data/web/views/busy.erb +15 -8
  89. data/web/views/dashboard.erb +1 -1
  90. data/web/views/dead.erb +2 -2
  91. data/web/views/layout.erb +12 -2
  92. data/web/views/morgue.erb +9 -6
  93. data/web/views/queue.erb +18 -8
  94. data/web/views/queues.erb +11 -1
  95. data/web/views/retries.erb +14 -7
  96. data/web/views/retry.erb +2 -2
  97. data/web/views/scheduled.erb +7 -4
  98. metadata +41 -188
  99. data/.github/issue_template.md +0 -9
  100. data/.travis.yml +0 -18
  101. data/bin/sidekiqctl +0 -99
  102. data/lib/sidekiq/core_ext.rb +0 -119
  103. data/lib/sidekiq/logging.rb +0 -106
  104. data/lib/sidekiq/middleware/server/active_record.rb +0 -13
  105. data/lib/sidekiq/middleware/server/logging.rb +0 -31
  106. data/lib/sidekiq/middleware/server/retry_jobs.rb +0 -205
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Sidekiq
4
4
  class WebAction
5
- RACK_SESSION = 'rack.session'.freeze
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
@@ -39,15 +39,15 @@ module Sidekiq
39
39
  env[RACK_SESSION]
40
40
  end
41
41
 
42
- def content_type(type)
43
- @type = type
44
- end
45
-
46
42
  def erb(content, options = {})
47
- if content.kind_of? Symbol
43
+ if content.is_a? Symbol
48
44
  unless respond_to?(:"_erb_#{content}")
49
45
  src = ERB.new(File.read("#{Web.settings.views}/#{content}.erb")).src
50
- 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
51
51
  end
52
52
  end
53
53
 
@@ -68,22 +68,22 @@ module Sidekiq
68
68
  end
69
69
 
70
70
  def json(payload)
71
- [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)]]
72
72
  end
73
73
 
74
74
  def initialize(env, block)
75
75
  @_erb = false
76
76
  @env = env
77
77
  @block = block
78
- @@files ||= {}
78
+ @files ||= {}
79
79
  end
80
80
 
81
81
  private
82
82
 
83
83
  def _erb(file, locals)
84
- locals.each {|k, v| define_singleton_method(k){ v } } if locals
84
+ locals&.each { |k, v| define_singleton_method(k) { v } unless singleton_methods.include? k }
85
85
 
86
- if file.kind_of?(String)
86
+ if file.is_a?(String)
87
87
  ERB.new(file).result(binding)
88
88
  else
89
89
  send(:"_erb_#{file}")
@@ -4,9 +4,23 @@ module Sidekiq
4
4
  class WebApplication
5
5
  extend WebRouter
6
6
 
7
- CONTENT_LENGTH = "Content-Length".freeze
8
- CONTENT_TYPE = "Content-Type".freeze
9
- REDIS_KEYS = %w(redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human)
7
+ CONTENT_LENGTH = "Content-Length"
8
+ REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human]
9
+ CSP_HEADER = [
10
+ "default-src 'self' https: http:",
11
+ "child-src 'self'",
12
+ "connect-src 'self' https: http: wss: ws:",
13
+ "font-src 'self' https: http:",
14
+ "frame-src 'self'",
15
+ "img-src 'self' https: http: data:",
16
+ "manifest-src 'self'",
17
+ "media-src 'self'",
18
+ "object-src 'none'",
19
+ "script-src 'self' https: http: 'unsafe-inline'",
20
+ "style-src 'self' https: http: 'unsafe-inline'",
21
+ "worker-src 'self'",
22
+ "base-uri 'self'"
23
+ ].join("; ").freeze
10
24
 
11
25
  def initialize(klass)
12
26
  @klass = klass
@@ -29,8 +43,8 @@ module Sidekiq
29
43
  end
30
44
 
31
45
  get "/" do
32
- @redis_info = redis_info.select{ |k, v| REDIS_KEYS.include? k }
33
- 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)
34
48
  @processed_history = stats_history.processed
35
49
  @failed_history = stats_history.failed
36
50
 
@@ -42,14 +56,14 @@ module Sidekiq
42
56
  end
43
57
 
44
58
  post "/busy" do
45
- if params['identity']
46
- p = Sidekiq::Process.new('identity' => params['identity'])
47
- p.quiet! if params['quiet']
48
- 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"]
49
63
  else
50
64
  processes.each do |pro|
51
- pro.quiet! if params['quiet']
52
- pro.stop! if params['stop']
65
+ pro.quiet! if params["quiet"]
66
+ pro.stop! if params["stop"]
53
67
  end
54
68
  end
55
69
 
@@ -67,37 +81,46 @@ module Sidekiq
67
81
 
68
82
  halt(404) unless @name
69
83
 
70
- @count = (params['count'] || 25).to_i
84
+ @count = (params["count"] || 25).to_i
71
85
  @queue = Sidekiq::Queue.new(@name)
72
- (@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")
73
87
  @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) }
74
88
 
75
89
  erb(:queue)
76
90
  end
77
91
 
78
92
  post "/queues/:name" do
79
- 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
80
102
 
81
103
  redirect "#{root_path}queues"
82
104
  end
83
105
 
84
106
  post "/queues/:name/delete" do
85
107
  name = route_params[:name]
86
- Sidekiq::Job.new(params['key_val'], name).delete
108
+ Sidekiq::Job.new(params["key_val"], name).delete
87
109
 
88
110
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
89
111
  end
90
112
 
91
- get '/morgue' do
92
- @count = (params['count'] || 25).to_i
93
- (@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)
94
116
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
95
117
 
96
118
  erb(:morgue)
97
119
  end
98
120
 
99
121
  get "/morgue/:key" do
100
- halt(404) unless key = route_params[:key]
122
+ key = route_params[:key]
123
+ halt(404) unless key
101
124
 
102
125
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
103
126
 
@@ -108,10 +131,10 @@ module Sidekiq
108
131
  end
109
132
  end
110
133
 
111
- post '/morgue' do
112
- redirect(request.path) unless params['key']
134
+ post "/morgue" do
135
+ redirect(request.path) unless params["key"]
113
136
 
114
- params['key'].each do |key|
137
+ params["key"].each do |key|
115
138
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
116
139
  retry_or_delete_or_kill job, params if job
117
140
  end
@@ -132,7 +155,8 @@ module Sidekiq
132
155
  end
133
156
 
134
157
  post "/morgue/:key" do
135
- halt(404) unless key = route_params[:key]
158
+ key = route_params[:key]
159
+ halt(404) unless key
136
160
 
137
161
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
138
162
  retry_or_delete_or_kill job, params if job
@@ -140,9 +164,9 @@ module Sidekiq
140
164
  redirect_with_query("#{root_path}morgue")
141
165
  end
142
166
 
143
- get '/retries' do
144
- @count = (params['count'] || 25).to_i
145
- (@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)
146
170
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
147
171
 
148
172
  erb(:retries)
@@ -158,10 +182,10 @@ module Sidekiq
158
182
  end
159
183
  end
160
184
 
161
- post '/retries' do
162
- redirect(request.path) unless params['key']
185
+ post "/retries" do
186
+ redirect(request.path) unless params["key"]
163
187
 
164
- params['key'].each do |key|
188
+ params["key"].each do |key|
165
189
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
166
190
  retry_or_delete_or_kill job, params if job
167
191
  end
@@ -181,6 +205,12 @@ module Sidekiq
181
205
  redirect "#{root_path}retries"
182
206
  end
183
207
 
208
+ post "/retries/all/kill" do
209
+ Sidekiq::RetrySet.new.kill_all
210
+
211
+ redirect "#{root_path}retries"
212
+ end
213
+
184
214
  post "/retries/:key" do
185
215
  job = Sidekiq::RetrySet.new.fetch(*parse_params(route_params[:key])).first
186
216
 
@@ -189,9 +219,9 @@ module Sidekiq
189
219
  redirect_with_query("#{root_path}retries")
190
220
  end
191
221
 
192
- get '/scheduled' do
193
- @count = (params['count'] || 25).to_i
194
- (@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)
195
225
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
196
226
 
197
227
  erb(:scheduled)
@@ -207,10 +237,10 @@ module Sidekiq
207
237
  end
208
238
  end
209
239
 
210
- post '/scheduled' do
211
- redirect(request.path) unless params['key']
240
+ post "/scheduled" do
241
+ redirect(request.path) unless params["key"]
212
242
 
213
- params['key'].each do |key|
243
+ params["key"].each do |key|
214
244
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
215
245
  delete_or_add_queue job, params if job
216
246
  end
@@ -219,7 +249,8 @@ module Sidekiq
219
249
  end
220
250
 
221
251
  post "/scheduled/:key" do
222
- halt(404) unless key = route_params[:key]
252
+ key = route_params[:key]
253
+ halt(404) unless key
223
254
 
224
255
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
225
256
  delete_or_add_queue job, params if job
@@ -227,76 +258,64 @@ module Sidekiq
227
258
  redirect_with_query("#{root_path}scheduled")
228
259
  end
229
260
 
230
- get '/dashboard/stats' do
261
+ get "/dashboard/stats" do
231
262
  redirect "#{root_path}stats"
232
263
  end
233
264
 
234
- get '/stats' do
265
+ get "/stats" do
235
266
  sidekiq_stats = Sidekiq::Stats.new
236
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
237
-
267
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
238
268
  json(
239
269
  sidekiq: {
240
- processed: sidekiq_stats.processed,
241
- failed: sidekiq_stats.failed,
242
- busy: sidekiq_stats.workers_size,
243
- processes: sidekiq_stats.processes_size,
244
- enqueued: sidekiq_stats.enqueued,
245
- scheduled: sidekiq_stats.scheduled_size,
246
- retries: sidekiq_stats.retry_size,
247
- 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,
248
278
  default_latency: sidekiq_stats.default_queue_latency
249
279
  },
250
- redis: redis_stats
280
+ redis: redis_stats,
281
+ server_utc_time: server_utc_time
251
282
  )
252
283
  end
253
284
 
254
- get '/stats/queues' do
285
+ get "/stats/queues" do
255
286
  json Sidekiq::Stats::Queues.new.lengths
256
287
  end
257
288
 
258
289
  def call(env)
259
290
  action = self.class.match(env)
260
- 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
261
292
 
262
- resp = catch(:halt) do
263
- app = @klass
293
+ app = @klass
294
+ resp = catch(:halt) do # rubocop:disable Standard/SemanticBlocks
264
295
  self.class.run_befores(app, action)
265
- begin
266
- resp = action.instance_exec env, &action.block
267
- ensure
268
- self.class.run_afters(app, action)
269
- end
270
-
271
- resp
296
+ action.instance_exec env, &action.block
297
+ ensure
298
+ self.class.run_afters(app, action)
272
299
  end
273
300
 
274
- resp = case resp
301
+ case resp
275
302
  when Array
303
+ # redirects go here
276
304
  resp
277
- when Integer
278
- [resp, {}, []]
279
305
  else
280
- type_header = case action.type
281
- when :json
282
- { "Content-Type" => "application/json", "Cache-Control" => "no-cache" }
283
- when String
284
- { "Content-Type" => (action.type || "text/html"), "Cache-Control" => "no-cache" }
285
- else
286
- { "Content-Type" => "text/html", "Cache-Control" => "no-cache" }
287
- end
288
-
289
- [200, type_header, [resp]]
306
+ # rendered content goes here
307
+ headers = {
308
+ "Content-Type" => "text/html",
309
+ "Cache-Control" => "no-cache",
310
+ "Content-Language" => action.locale,
311
+ "Content-Security-Policy" => CSP_HEADER
312
+ }
313
+ # we'll let Rack calculate Content-Length for us.
314
+ [200, headers, [resp]]
290
315
  end
291
-
292
- resp[1] = resp[1].dup
293
-
294
- resp[1][CONTENT_LENGTH] = resp[2].inject(0) { |l, p| l + p.bytesize }.to_s
295
-
296
- resp
297
316
  end
298
317
 
299
- def self.helpers(mod=nil, &block)
318
+ def self.helpers(mod = nil, &block)
300
319
  if block_given?
301
320
  WebAction.class_eval(&block)
302
321
  else
@@ -304,11 +323,11 @@ module Sidekiq
304
323
  end
305
324
  end
306
325
 
307
- def self.before(path=nil, &block)
326
+ def self.before(path = nil, &block)
308
327
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
309
328
  end
310
329
 
311
- def self.after(path=nil, &block)
330
+ def self.after(path = nil, &block)
312
331
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
313
332
  end
314
333
 
@@ -321,8 +340,8 @@ module Sidekiq
321
340
  end
322
341
 
323
342
  def self.run_hooks(hooks, app, action)
324
- hooks.select { |p,_| !p || p =~ action.env[WebRouter::PATH_INFO] }.
325
- 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) }
326
345
  end
327
346
 
328
347
  def self.befores
@@ -0,0 +1,158 @@
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
+
94
+ # Checks that Rack::Session::Cookie did not return empty session
95
+ # object in case the digest verification failed
96
+ return false if sess.empty?
97
+
98
+ localtoken = sess[:csrf]
99
+
100
+ # Rotate the session token after every use
101
+ sess[:csrf] = SecureRandom.base64(TOKEN_LENGTH)
102
+
103
+ # See if it's actually a masked token or not. We should be able
104
+ # to handle any unmasked tokens that we've issued without error.
105
+
106
+ if unmasked_token?(token)
107
+ compare_with_real_token token, localtoken
108
+ elsif masked_token?(token)
109
+ unmasked = unmask_token(token)
110
+ compare_with_real_token unmasked, localtoken
111
+ else
112
+ false # Token is malformed
113
+ end
114
+ end
115
+
116
+ # Creates a masked version of the authenticity token that varies
117
+ # on each request. The masking is used to mitigate SSL attacks
118
+ # like BREACH.
119
+ def mask_token(token)
120
+ token = decode_token(token)
121
+ one_time_pad = SecureRandom.random_bytes(token.length)
122
+ encrypted_token = xor_byte_strings(one_time_pad, token)
123
+ masked_token = one_time_pad + encrypted_token
124
+ Base64.strict_encode64(masked_token)
125
+ end
126
+
127
+ # Essentially the inverse of +mask_token+.
128
+ def unmask_token(masked_token)
129
+ # Split the token into the one-time pad and the encrypted
130
+ # value and decrypt it
131
+ token_length = masked_token.length / 2
132
+ one_time_pad = masked_token[0...token_length]
133
+ encrypted_token = masked_token[token_length..-1]
134
+ xor_byte_strings(one_time_pad, encrypted_token)
135
+ end
136
+
137
+ def unmasked_token?(token)
138
+ token.length == TOKEN_LENGTH
139
+ end
140
+
141
+ def masked_token?(token)
142
+ token.length == TOKEN_LENGTH * 2
143
+ end
144
+
145
+ def compare_with_real_token(token, local)
146
+ ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
147
+ end
148
+
149
+ def decode_token(token)
150
+ Base64.strict_decode64(token)
151
+ end
152
+
153
+ def xor_byte_strings(s1, s2)
154
+ s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*")
155
+ end
156
+ end
157
+ end
158
+ end