sidekiq 4.2.10 → 6.5.7

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 (131) hide show
  1. checksums.yaml +5 -5
  2. data/Changes.md +573 -1
  3. data/LICENSE +3 -3
  4. data/README.md +25 -34
  5. data/bin/sidekiq +27 -3
  6. data/bin/sidekiqload +81 -74
  7. data/bin/sidekiqmon +8 -0
  8. data/lib/generators/sidekiq/job_generator.rb +57 -0
  9. data/lib/generators/sidekiq/templates/{worker.rb.erb → job.rb.erb} +2 -2
  10. data/lib/generators/sidekiq/templates/job_spec.rb.erb +6 -0
  11. data/lib/generators/sidekiq/templates/{worker_test.rb.erb → job_test.rb.erb} +1 -1
  12. data/lib/sidekiq/api.rb +585 -285
  13. data/lib/sidekiq/cli.rb +256 -233
  14. data/lib/sidekiq/client.rb +86 -83
  15. data/lib/sidekiq/component.rb +65 -0
  16. data/lib/sidekiq/delay.rb +43 -0
  17. data/lib/sidekiq/extensions/action_mailer.rb +13 -22
  18. data/lib/sidekiq/extensions/active_record.rb +13 -10
  19. data/lib/sidekiq/extensions/class_methods.rb +14 -11
  20. data/lib/sidekiq/extensions/generic_proxy.rb +13 -5
  21. data/lib/sidekiq/fetch.rb +50 -40
  22. data/lib/sidekiq/job.rb +13 -0
  23. data/lib/sidekiq/job_logger.rb +51 -0
  24. data/lib/sidekiq/job_retry.rb +282 -0
  25. data/lib/sidekiq/job_util.rb +71 -0
  26. data/lib/sidekiq/launcher.rb +184 -90
  27. data/lib/sidekiq/logger.rb +156 -0
  28. data/lib/sidekiq/manager.rb +43 -45
  29. data/lib/sidekiq/metrics/deploy.rb +47 -0
  30. data/lib/sidekiq/metrics/query.rb +153 -0
  31. data/lib/sidekiq/metrics/shared.rb +94 -0
  32. data/lib/sidekiq/metrics/tracking.rb +134 -0
  33. data/lib/sidekiq/middleware/chain.rb +102 -46
  34. data/lib/sidekiq/middleware/current_attributes.rb +63 -0
  35. data/lib/sidekiq/middleware/i18n.rb +7 -7
  36. data/lib/sidekiq/middleware/modules.rb +21 -0
  37. data/lib/sidekiq/monitor.rb +133 -0
  38. data/lib/sidekiq/paginator.rb +20 -16
  39. data/lib/sidekiq/processor.rb +176 -91
  40. data/lib/sidekiq/rails.rb +41 -96
  41. data/lib/sidekiq/redis_client_adapter.rb +154 -0
  42. data/lib/sidekiq/redis_connection.rb +117 -48
  43. data/lib/sidekiq/ring_buffer.rb +29 -0
  44. data/lib/sidekiq/scheduled.rb +134 -44
  45. data/lib/sidekiq/sd_notify.rb +149 -0
  46. data/lib/sidekiq/systemd.rb +24 -0
  47. data/lib/sidekiq/testing/inline.rb +6 -5
  48. data/lib/sidekiq/testing.rb +80 -61
  49. data/lib/sidekiq/transaction_aware_client.rb +45 -0
  50. data/lib/sidekiq/version.rb +2 -1
  51. data/lib/sidekiq/web/action.rb +15 -15
  52. data/lib/sidekiq/web/application.rb +129 -86
  53. data/lib/sidekiq/web/csrf_protection.rb +180 -0
  54. data/lib/sidekiq/web/helpers.rb +170 -83
  55. data/lib/sidekiq/web/router.rb +23 -19
  56. data/lib/sidekiq/web.rb +69 -109
  57. data/lib/sidekiq/worker.rb +290 -41
  58. data/lib/sidekiq.rb +185 -77
  59. data/sidekiq.gemspec +23 -27
  60. data/web/assets/images/apple-touch-icon.png +0 -0
  61. data/web/assets/javascripts/application.js +112 -61
  62. data/web/assets/javascripts/chart.min.js +13 -0
  63. data/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
  64. data/web/assets/javascripts/dashboard.js +70 -91
  65. data/web/assets/javascripts/graph.js +16 -0
  66. data/web/assets/javascripts/metrics.js +262 -0
  67. data/web/assets/stylesheets/application-dark.css +143 -0
  68. data/web/assets/stylesheets/application-rtl.css +242 -0
  69. data/web/assets/stylesheets/application.css +364 -144
  70. data/web/assets/stylesheets/bootstrap-rtl.min.css +9 -0
  71. data/web/assets/stylesheets/bootstrap.css +2 -2
  72. data/web/locales/ar.yml +87 -0
  73. data/web/locales/de.yml +14 -2
  74. data/web/locales/el.yml +43 -19
  75. data/web/locales/en.yml +15 -1
  76. data/web/locales/es.yml +22 -5
  77. data/web/locales/fa.yml +1 -0
  78. data/web/locales/fr.yml +10 -3
  79. data/web/locales/he.yml +79 -0
  80. data/web/locales/ja.yml +19 -4
  81. data/web/locales/lt.yml +83 -0
  82. data/web/locales/pl.yml +4 -4
  83. data/web/locales/pt-br.yml +27 -9
  84. data/web/locales/ru.yml +4 -0
  85. data/web/locales/ur.yml +80 -0
  86. data/web/locales/vi.yml +83 -0
  87. data/web/locales/zh-cn.yml +36 -11
  88. data/web/locales/zh-tw.yml +32 -7
  89. data/web/views/_footer.erb +5 -2
  90. data/web/views/_job_info.erb +3 -2
  91. data/web/views/_nav.erb +5 -19
  92. data/web/views/_paging.erb +1 -1
  93. data/web/views/_poll_link.erb +2 -5
  94. data/web/views/_summary.erb +7 -7
  95. data/web/views/busy.erb +62 -24
  96. data/web/views/dashboard.erb +24 -15
  97. data/web/views/dead.erb +3 -3
  98. data/web/views/layout.erb +14 -3
  99. data/web/views/metrics.erb +69 -0
  100. data/web/views/metrics_for_job.erb +87 -0
  101. data/web/views/morgue.erb +9 -6
  102. data/web/views/queue.erb +26 -12
  103. data/web/views/queues.erb +12 -2
  104. data/web/views/retries.erb +14 -7
  105. data/web/views/retry.erb +3 -3
  106. data/web/views/scheduled.erb +7 -4
  107. metadata +66 -206
  108. data/.github/contributing.md +0 -32
  109. data/.github/issue_template.md +0 -9
  110. data/.gitignore +0 -12
  111. data/.travis.yml +0 -18
  112. data/3.0-Upgrade.md +0 -70
  113. data/4.0-Upgrade.md +0 -53
  114. data/COMM-LICENSE +0 -95
  115. data/Ent-Changes.md +0 -173
  116. data/Gemfile +0 -29
  117. data/Pro-2.0-Upgrade.md +0 -138
  118. data/Pro-3.0-Upgrade.md +0 -44
  119. data/Pro-Changes.md +0 -628
  120. data/Rakefile +0 -12
  121. data/bin/sidekiqctl +0 -99
  122. data/code_of_conduct.md +0 -50
  123. data/lib/generators/sidekiq/templates/worker_spec.rb.erb +0 -6
  124. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  125. data/lib/sidekiq/core_ext.rb +0 -119
  126. data/lib/sidekiq/exception_handler.rb +0 -31
  127. data/lib/sidekiq/logging.rb +0 -106
  128. data/lib/sidekiq/middleware/server/active_record.rb +0 -13
  129. data/lib/sidekiq/middleware/server/logging.rb +0 -31
  130. data/lib/sidekiq/middleware/server/retry_jobs.rb +0 -205
  131. data/lib/sidekiq/util.rb +0 -63
@@ -4,9 +4,22 @@ 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
+ REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human]
8
+ CSP_HEADER = [
9
+ "default-src 'self' https: http:",
10
+ "child-src 'self'",
11
+ "connect-src 'self' https: http: wss: ws:",
12
+ "font-src 'self' https: http:",
13
+ "frame-src 'self'",
14
+ "img-src 'self' https: http: data:",
15
+ "manifest-src 'self'",
16
+ "media-src 'self'",
17
+ "object-src 'none'",
18
+ "script-src 'self' https: http: 'unsafe-inline'",
19
+ "style-src 'self' https: http: 'unsafe-inline'",
20
+ "worker-src 'self'",
21
+ "base-uri 'self'"
22
+ ].join("; ").freeze
10
23
 
11
24
  def initialize(klass)
12
25
  @klass = klass
@@ -28,28 +41,51 @@ module Sidekiq
28
41
  # nothing, backwards compatibility
29
42
  end
30
43
 
44
+ head "/" do
45
+ # HEAD / is the cheapest heartbeat possible,
46
+ # it hits Redis to ensure connectivity
47
+ Sidekiq.redis { |c| c.llen("queue:default") }
48
+ ""
49
+ end
50
+
31
51
  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)
52
+ @redis_info = redis_info.select { |k, v| REDIS_KEYS.include? k }
53
+ days = (params["days"] || 30).to_i
54
+ return halt(401) if days < 1 || days > 180
55
+
56
+ stats_history = Sidekiq::Stats::History.new(days)
34
57
  @processed_history = stats_history.processed
35
58
  @failed_history = stats_history.failed
36
59
 
37
60
  erb(:dashboard)
38
61
  end
39
62
 
63
+ get "/metrics" do
64
+ q = Sidekiq::Metrics::Query.new
65
+ @query_result = q.top_jobs
66
+ erb(:metrics)
67
+ end
68
+
69
+ get "/metrics/:name" do
70
+ @name = route_params[:name]
71
+ q = Sidekiq::Metrics::Query.new
72
+ @query_result = q.for_job(@name)
73
+ erb(:metrics_for_job)
74
+ end
75
+
40
76
  get "/busy" do
41
77
  erb(:busy)
42
78
  end
43
79
 
44
80
  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']
81
+ if params["identity"]
82
+ p = Sidekiq::Process.new("identity" => params["identity"])
83
+ p.quiet! if params["quiet"]
84
+ p.stop! if params["stop"]
49
85
  else
50
86
  processes.each do |pro|
51
- pro.quiet! if params['quiet']
52
- pro.stop! if params['stop']
87
+ pro.quiet! if params["quiet"]
88
+ pro.stop! if params["stop"]
53
89
  end
54
90
  end
55
91
 
@@ -62,42 +98,53 @@ module Sidekiq
62
98
  erb(:queues)
63
99
  end
64
100
 
101
+ QUEUE_NAME = /\A[a-z_:.\-0-9]+\z/i
102
+
65
103
  get "/queues/:name" do
66
104
  @name = route_params[:name]
67
105
 
68
- halt(404) unless @name
106
+ halt(404) if !@name || @name !~ QUEUE_NAME
69
107
 
70
- @count = (params['count'] || 25).to_i
108
+ @count = (params["count"] || 25).to_i
71
109
  @queue = Sidekiq::Queue.new(@name)
72
- (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count)
73
- @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) }
110
+ (@current_page, @total_size, @jobs) = page("queue:#{@name}", params["page"], @count, reverse: params["direction"] == "asc")
111
+ @jobs = @jobs.map { |msg| Sidekiq::JobRecord.new(msg, @name) }
74
112
 
75
113
  erb(:queue)
76
114
  end
77
115
 
78
116
  post "/queues/:name" do
79
- Sidekiq::Queue.new(route_params[:name]).clear
117
+ queue = Sidekiq::Queue.new(route_params[:name])
118
+
119
+ if Sidekiq.pro? && params["pause"]
120
+ queue.pause!
121
+ elsif Sidekiq.pro? && params["unpause"]
122
+ queue.unpause!
123
+ else
124
+ queue.clear
125
+ end
80
126
 
81
127
  redirect "#{root_path}queues"
82
128
  end
83
129
 
84
130
  post "/queues/:name/delete" do
85
131
  name = route_params[:name]
86
- Sidekiq::Job.new(params['key_val'], name).delete
132
+ Sidekiq::JobRecord.new(params["key_val"], name).delete
87
133
 
88
134
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
89
135
  end
90
136
 
91
- get '/morgue' do
92
- @count = (params['count'] || 25).to_i
93
- (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true)
137
+ get "/morgue" do
138
+ @count = (params["count"] || 25).to_i
139
+ (@current_page, @total_size, @dead) = page("dead", params["page"], @count, reverse: true)
94
140
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
95
141
 
96
142
  erb(:morgue)
97
143
  end
98
144
 
99
145
  get "/morgue/:key" do
100
- halt(404) unless key = route_params[:key]
146
+ key = route_params[:key]
147
+ halt(404) unless key
101
148
 
102
149
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
103
150
 
@@ -108,10 +155,10 @@ module Sidekiq
108
155
  end
109
156
  end
110
157
 
111
- post '/morgue' do
112
- redirect(request.path) unless params['key']
158
+ post "/morgue" do
159
+ redirect(request.path) unless params["key"]
113
160
 
114
- params['key'].each do |key|
161
+ params["key"].each do |key|
115
162
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
116
163
  retry_or_delete_or_kill job, params if job
117
164
  end
@@ -132,7 +179,8 @@ module Sidekiq
132
179
  end
133
180
 
134
181
  post "/morgue/:key" do
135
- halt(404) unless key = route_params[:key]
182
+ key = route_params[:key]
183
+ halt(404) unless key
136
184
 
137
185
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
138
186
  retry_or_delete_or_kill job, params if job
@@ -140,9 +188,9 @@ module Sidekiq
140
188
  redirect_with_query("#{root_path}morgue")
141
189
  end
142
190
 
143
- get '/retries' do
144
- @count = (params['count'] || 25).to_i
145
- (@current_page, @total_size, @retries) = page("retry", params['page'], @count)
191
+ get "/retries" do
192
+ @count = (params["count"] || 25).to_i
193
+ (@current_page, @total_size, @retries) = page("retry", params["page"], @count)
146
194
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
147
195
 
148
196
  erb(:retries)
@@ -158,10 +206,10 @@ module Sidekiq
158
206
  end
159
207
  end
160
208
 
161
- post '/retries' do
162
- redirect(request.path) unless params['key']
209
+ post "/retries" do
210
+ redirect(request.path) unless params["key"]
163
211
 
164
- params['key'].each do |key|
212
+ params["key"].each do |key|
165
213
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
166
214
  retry_or_delete_or_kill job, params if job
167
215
  end
@@ -181,6 +229,12 @@ module Sidekiq
181
229
  redirect "#{root_path}retries"
182
230
  end
183
231
 
232
+ post "/retries/all/kill" do
233
+ Sidekiq::RetrySet.new.kill_all
234
+
235
+ redirect "#{root_path}retries"
236
+ end
237
+
184
238
  post "/retries/:key" do
185
239
  job = Sidekiq::RetrySet.new.fetch(*parse_params(route_params[:key])).first
186
240
 
@@ -189,9 +243,9 @@ module Sidekiq
189
243
  redirect_with_query("#{root_path}retries")
190
244
  end
191
245
 
192
- get '/scheduled' do
193
- @count = (params['count'] || 25).to_i
194
- (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count)
246
+ get "/scheduled" do
247
+ @count = (params["count"] || 25).to_i
248
+ (@current_page, @total_size, @scheduled) = page("schedule", params["page"], @count)
195
249
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
196
250
 
197
251
  erb(:scheduled)
@@ -207,10 +261,10 @@ module Sidekiq
207
261
  end
208
262
  end
209
263
 
210
- post '/scheduled' do
211
- redirect(request.path) unless params['key']
264
+ post "/scheduled" do
265
+ redirect(request.path) unless params["key"]
212
266
 
213
- params['key'].each do |key|
267
+ params["key"].each do |key|
214
268
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
215
269
  delete_or_add_queue job, params if job
216
270
  end
@@ -219,7 +273,8 @@ module Sidekiq
219
273
  end
220
274
 
221
275
  post "/scheduled/:key" do
222
- halt(404) unless key = route_params[:key]
276
+ key = route_params[:key]
277
+ halt(404) unless key
223
278
 
224
279
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
225
280
  delete_or_add_queue job, params if job
@@ -227,88 +282,76 @@ module Sidekiq
227
282
  redirect_with_query("#{root_path}scheduled")
228
283
  end
229
284
 
230
- get '/dashboard/stats' do
285
+ get "/dashboard/stats" do
231
286
  redirect "#{root_path}stats"
232
287
  end
233
288
 
234
- get '/stats' do
289
+ get "/stats" do
235
290
  sidekiq_stats = Sidekiq::Stats.new
236
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
237
-
291
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
238
292
  json(
239
293
  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,
294
+ processed: sidekiq_stats.processed,
295
+ failed: sidekiq_stats.failed,
296
+ busy: sidekiq_stats.workers_size,
297
+ processes: sidekiq_stats.processes_size,
298
+ enqueued: sidekiq_stats.enqueued,
299
+ scheduled: sidekiq_stats.scheduled_size,
300
+ retries: sidekiq_stats.retry_size,
301
+ dead: sidekiq_stats.dead_size,
248
302
  default_latency: sidekiq_stats.default_queue_latency
249
303
  },
250
- redis: redis_stats
304
+ redis: redis_stats,
305
+ server_utc_time: server_utc_time
251
306
  )
252
307
  end
253
308
 
254
- get '/stats/queues' do
309
+ get "/stats/queues" do
255
310
  json Sidekiq::Stats::Queues.new.lengths
256
311
  end
257
312
 
258
313
  def call(env)
259
314
  action = self.class.match(env)
260
- return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
315
+ return [404, {"content-type" => "text/plain", "x-cascade" => "pass"}, ["Not Found"]] unless action
261
316
 
317
+ app = @klass
262
318
  resp = catch(:halt) do
263
- app = @klass
264
319
  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
320
+ action.instance_exec env, &action.block
321
+ ensure
322
+ self.class.run_afters(app, action)
272
323
  end
273
324
 
274
- resp = case resp
325
+ case resp
275
326
  when Array
327
+ # redirects go here
276
328
  resp
277
- when Integer
278
- [resp, {}, []]
279
329
  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]]
330
+ # rendered content goes here
331
+ headers = {
332
+ "content-type" => "text/html",
333
+ "cache-control" => "private, no-store",
334
+ "content-language" => action.locale,
335
+ "content-security-policy" => CSP_HEADER
336
+ }
337
+ # we'll let Rack calculate Content-Length for us.
338
+ [200, headers, [resp]]
290
339
  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
340
  end
298
341
 
299
- def self.helpers(mod=nil, &block)
300
- if block_given?
342
+ def self.helpers(mod = nil, &block)
343
+ if block
301
344
  WebAction.class_eval(&block)
302
345
  else
303
346
  WebAction.send(:include, mod)
304
347
  end
305
348
  end
306
349
 
307
- def self.before(path=nil, &block)
350
+ def self.before(path = nil, &block)
308
351
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
309
352
  end
310
353
 
311
- def self.after(path=nil, &block)
354
+ def self.after(path = nil, &block)
312
355
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
313
356
  end
314
357
 
@@ -321,8 +364,8 @@ module Sidekiq
321
364
  end
322
365
 
323
366
  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) }
367
+ hooks.select { |p, _| !p || p =~ action.env[WebRouter::PATH_INFO] }
368
+ .each { |_, b| action.instance_exec(action.env, app, &b) }
326
369
  end
327
370
 
328
371
  def self.befores
@@ -0,0 +1,180 @@
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(<<~EOM)
70
+ Sidekiq::Web needs a valid Rack session for CSRF protection. If this is a Rails app,
71
+ make sure you mount Sidekiq::Web *inside* your application routes:
72
+
73
+
74
+ Rails.application.routes.draw do
75
+ mount Sidekiq::Web => "/sidekiq"
76
+ ....
77
+ end
78
+
79
+
80
+ If this is a Rails app in API mode, you need to enable sessions.
81
+
82
+ https://guides.rubyonrails.org/api_app.html#using-session-middlewares
83
+
84
+ If this is a bare Rack app, use a session middleware before Sidekiq::Web:
85
+
86
+ # first, use IRB to create a shared secret key for sessions and commit it
87
+ require 'securerandom'; File.open(".session.key", "w") {|f| f.write(SecureRandom.hex(32)) }
88
+
89
+ # now use the secret with a session cookie middleware
90
+ use Rack::Session::Cookie, secret: File.read(".session.key"), same_site: true, max_age: 86400
91
+ run Sidekiq::Web
92
+
93
+ EOM
94
+ end
95
+
96
+ def accept?(env)
97
+ return true if safe?(env)
98
+
99
+ giventoken = ::Rack::Request.new(env).params["authenticity_token"]
100
+ valid_token?(env, giventoken)
101
+ end
102
+
103
+ TOKEN_LENGTH = 32
104
+
105
+ # Checks that the token given to us as a parameter matches
106
+ # the token stored in the session.
107
+ def valid_token?(env, giventoken)
108
+ return false if giventoken.nil? || giventoken.empty?
109
+
110
+ begin
111
+ token = decode_token(giventoken)
112
+ rescue ArgumentError # client input is invalid
113
+ return false
114
+ end
115
+
116
+ sess = session(env)
117
+ localtoken = sess[:csrf]
118
+
119
+ # Checks that Rack::Session::Cookie actualy contains the csrf toekn
120
+ return false if localtoken.nil?
121
+
122
+ # Rotate the session token after every use
123
+ sess[:csrf] = SecureRandom.base64(TOKEN_LENGTH)
124
+
125
+ # See if it's actually a masked token or not. We should be able
126
+ # to handle any unmasked tokens that we've issued without error.
127
+
128
+ if unmasked_token?(token)
129
+ compare_with_real_token token, localtoken
130
+ elsif masked_token?(token)
131
+ unmasked = unmask_token(token)
132
+ compare_with_real_token unmasked, localtoken
133
+ else
134
+ false # Token is malformed
135
+ end
136
+ end
137
+
138
+ # Creates a masked version of the authenticity token that varies
139
+ # on each request. The masking is used to mitigate SSL attacks
140
+ # like BREACH.
141
+ def mask_token(token)
142
+ token = decode_token(token)
143
+ one_time_pad = SecureRandom.random_bytes(token.length)
144
+ encrypted_token = xor_byte_strings(one_time_pad, token)
145
+ masked_token = one_time_pad + encrypted_token
146
+ Base64.urlsafe_encode64(masked_token)
147
+ end
148
+
149
+ # Essentially the inverse of +mask_token+.
150
+ def unmask_token(masked_token)
151
+ # Split the token into the one-time pad and the encrypted
152
+ # value and decrypt it
153
+ token_length = masked_token.length / 2
154
+ one_time_pad = masked_token[0...token_length]
155
+ encrypted_token = masked_token[token_length..-1]
156
+ xor_byte_strings(one_time_pad, encrypted_token)
157
+ end
158
+
159
+ def unmasked_token?(token)
160
+ token.length == TOKEN_LENGTH
161
+ end
162
+
163
+ def masked_token?(token)
164
+ token.length == TOKEN_LENGTH * 2
165
+ end
166
+
167
+ def compare_with_real_token(token, local)
168
+ ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
169
+ end
170
+
171
+ def decode_token(token)
172
+ Base64.urlsafe_decode64(token)
173
+ end
174
+
175
+ def xor_byte_strings(s1, s2)
176
+ s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*")
177
+ end
178
+ end
179
+ end
180
+ end