sidekiq 5.2.6 → 7.1.0

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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +537 -8
  3. data/LICENSE.txt +9 -0
  4. data/README.md +47 -50
  5. data/bin/sidekiq +22 -3
  6. data/bin/sidekiqload +213 -115
  7. data/bin/sidekiqmon +11 -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/{worker_spec.rb.erb → job_spec.rb.erb} +1 -1
  11. data/lib/generators/sidekiq/templates/{worker_test.rb.erb → job_test.rb.erb} +1 -1
  12. data/lib/sidekiq/api.rb +556 -351
  13. data/lib/sidekiq/capsule.rb +127 -0
  14. data/lib/sidekiq/cli.rb +203 -226
  15. data/lib/sidekiq/client.rb +121 -101
  16. data/lib/sidekiq/component.rb +68 -0
  17. data/lib/sidekiq/config.rb +274 -0
  18. data/lib/sidekiq/deploy.rb +62 -0
  19. data/lib/sidekiq/embedded.rb +61 -0
  20. data/lib/sidekiq/fetch.rb +49 -42
  21. data/lib/sidekiq/job.rb +374 -0
  22. data/lib/sidekiq/job_logger.rb +33 -7
  23. data/lib/sidekiq/job_retry.rb +131 -108
  24. data/lib/sidekiq/job_util.rb +105 -0
  25. data/lib/sidekiq/launcher.rb +203 -105
  26. data/lib/sidekiq/logger.rb +131 -0
  27. data/lib/sidekiq/manager.rb +43 -46
  28. data/lib/sidekiq/metrics/query.rb +153 -0
  29. data/lib/sidekiq/metrics/shared.rb +95 -0
  30. data/lib/sidekiq/metrics/tracking.rb +136 -0
  31. data/lib/sidekiq/middleware/chain.rb +113 -56
  32. data/lib/sidekiq/middleware/current_attributes.rb +56 -0
  33. data/lib/sidekiq/middleware/i18n.rb +7 -7
  34. data/lib/sidekiq/middleware/modules.rb +21 -0
  35. data/lib/sidekiq/monitor.rb +146 -0
  36. data/lib/sidekiq/paginator.rb +28 -16
  37. data/lib/sidekiq/processor.rb +108 -107
  38. data/lib/sidekiq/rails.rb +49 -38
  39. data/lib/sidekiq/redis_client_adapter.rb +96 -0
  40. data/lib/sidekiq/redis_connection.rb +38 -107
  41. data/lib/sidekiq/ring_buffer.rb +29 -0
  42. data/lib/sidekiq/scheduled.rb +111 -49
  43. data/lib/sidekiq/sd_notify.rb +149 -0
  44. data/lib/sidekiq/systemd.rb +24 -0
  45. data/lib/sidekiq/testing/inline.rb +6 -5
  46. data/lib/sidekiq/testing.rb +66 -84
  47. data/lib/sidekiq/transaction_aware_client.rb +44 -0
  48. data/lib/sidekiq/version.rb +3 -1
  49. data/lib/sidekiq/web/action.rb +15 -11
  50. data/lib/sidekiq/web/application.rb +123 -79
  51. data/lib/sidekiq/web/csrf_protection.rb +180 -0
  52. data/lib/sidekiq/web/helpers.rb +137 -106
  53. data/lib/sidekiq/web/router.rb +23 -19
  54. data/lib/sidekiq/web.rb +56 -107
  55. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  56. data/lib/sidekiq.rb +92 -182
  57. data/sidekiq.gemspec +25 -16
  58. data/web/assets/images/apple-touch-icon.png +0 -0
  59. data/web/assets/javascripts/application.js +130 -61
  60. data/web/assets/javascripts/base-charts.js +106 -0
  61. data/web/assets/javascripts/chart.min.js +13 -0
  62. data/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
  63. data/web/assets/javascripts/dashboard-charts.js +166 -0
  64. data/web/assets/javascripts/dashboard.js +36 -292
  65. data/web/assets/javascripts/metrics.js +264 -0
  66. data/web/assets/stylesheets/application-dark.css +147 -0
  67. data/web/assets/stylesheets/application-rtl.css +2 -95
  68. data/web/assets/stylesheets/application.css +102 -522
  69. data/web/locales/ar.yml +71 -65
  70. data/web/locales/cs.yml +62 -62
  71. data/web/locales/da.yml +60 -53
  72. data/web/locales/de.yml +65 -53
  73. data/web/locales/el.yml +43 -24
  74. data/web/locales/en.yml +84 -66
  75. data/web/locales/es.yml +70 -54
  76. data/web/locales/fa.yml +65 -65
  77. data/web/locales/fr.yml +83 -62
  78. data/web/locales/gd.yml +99 -0
  79. data/web/locales/he.yml +65 -64
  80. data/web/locales/hi.yml +59 -59
  81. data/web/locales/it.yml +53 -53
  82. data/web/locales/ja.yml +75 -64
  83. data/web/locales/ko.yml +52 -52
  84. data/web/locales/lt.yml +83 -0
  85. data/web/locales/nb.yml +61 -61
  86. data/web/locales/nl.yml +52 -52
  87. data/web/locales/pl.yml +45 -45
  88. data/web/locales/pt-br.yml +63 -55
  89. data/web/locales/pt.yml +51 -51
  90. data/web/locales/ru.yml +68 -63
  91. data/web/locales/sv.yml +53 -53
  92. data/web/locales/ta.yml +60 -60
  93. data/web/locales/uk.yml +62 -61
  94. data/web/locales/ur.yml +64 -64
  95. data/web/locales/vi.yml +83 -0
  96. data/web/locales/zh-cn.yml +43 -16
  97. data/web/locales/zh-tw.yml +42 -8
  98. data/web/views/_footer.erb +6 -3
  99. data/web/views/_job_info.erb +21 -4
  100. data/web/views/_metrics_period_select.erb +12 -0
  101. data/web/views/_nav.erb +1 -1
  102. data/web/views/_paging.erb +2 -0
  103. data/web/views/_poll_link.erb +3 -6
  104. data/web/views/_summary.erb +7 -7
  105. data/web/views/busy.erb +75 -25
  106. data/web/views/dashboard.erb +58 -18
  107. data/web/views/dead.erb +3 -3
  108. data/web/views/layout.erb +3 -1
  109. data/web/views/metrics.erb +82 -0
  110. data/web/views/metrics_for_job.erb +68 -0
  111. data/web/views/morgue.erb +14 -15
  112. data/web/views/queue.erb +33 -24
  113. data/web/views/queues.erb +13 -3
  114. data/web/views/retries.erb +16 -17
  115. data/web/views/retry.erb +3 -3
  116. data/web/views/scheduled.erb +17 -15
  117. metadata +69 -69
  118. data/.github/contributing.md +0 -32
  119. data/.github/issue_template.md +0 -11
  120. data/.gitignore +0 -15
  121. data/.travis.yml +0 -11
  122. data/3.0-Upgrade.md +0 -70
  123. data/4.0-Upgrade.md +0 -53
  124. data/5.0-Upgrade.md +0 -56
  125. data/COMM-LICENSE +0 -97
  126. data/Ent-Changes.md +0 -238
  127. data/Gemfile +0 -23
  128. data/LICENSE +0 -9
  129. data/Pro-2.0-Upgrade.md +0 -138
  130. data/Pro-3.0-Upgrade.md +0 -44
  131. data/Pro-4.0-Upgrade.md +0 -35
  132. data/Pro-Changes.md +0 -759
  133. data/Rakefile +0 -9
  134. data/bin/sidekiqctl +0 -20
  135. data/code_of_conduct.md +0 -50
  136. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  137. data/lib/sidekiq/core_ext.rb +0 -1
  138. data/lib/sidekiq/ctl.rb +0 -221
  139. data/lib/sidekiq/delay.rb +0 -42
  140. data/lib/sidekiq/exception_handler.rb +0 -29
  141. data/lib/sidekiq/extensions/action_mailer.rb +0 -57
  142. data/lib/sidekiq/extensions/active_record.rb +0 -40
  143. data/lib/sidekiq/extensions/class_methods.rb +0 -40
  144. data/lib/sidekiq/extensions/generic_proxy.rb +0 -31
  145. data/lib/sidekiq/logging.rb +0 -122
  146. data/lib/sidekiq/middleware/server/active_record.rb +0 -23
  147. data/lib/sidekiq/util.rb +0 -66
  148. data/lib/sidekiq/worker.rb +0 -220
@@ -4,9 +4,7 @@ module Sidekiq
4
4
  class WebApplication
5
5
  extend WebRouter
6
6
 
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)
7
+ REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human]
10
8
  CSP_HEADER = [
11
9
  "default-src 'self' https: http:",
12
10
  "child-src 'self'",
@@ -21,7 +19,13 @@ module Sidekiq
21
19
  "style-src 'self' https: http: 'unsafe-inline'",
22
20
  "worker-src 'self'",
23
21
  "base-uri 'self'"
24
- ].join('; ').freeze
22
+ ].join("; ").freeze
23
+ METRICS_PERIODS = {
24
+ "1h" => 60,
25
+ "2h" => 120,
26
+ "4h" => 240,
27
+ "8h" => 480
28
+ }
25
29
 
26
30
  def initialize(klass)
27
31
  @klass = klass
@@ -43,28 +47,63 @@ module Sidekiq
43
47
  # nothing, backwards compatibility
44
48
  end
45
49
 
50
+ head "/" do
51
+ # HEAD / is the cheapest heartbeat possible,
52
+ # it hits Redis to ensure connectivity
53
+ Sidekiq.redis { |c| c.llen("queue:default") }
54
+ ""
55
+ end
56
+
46
57
  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)
58
+ @redis_info = redis_info.select { |k, v| REDIS_KEYS.include? k }
59
+ days = (params["days"] || 30).to_i
60
+ return halt(401) if days < 1 || days > 180
61
+
62
+ stats_history = Sidekiq::Stats::History.new(days)
49
63
  @processed_history = stats_history.processed
50
64
  @failed_history = stats_history.failed
51
65
 
52
66
  erb(:dashboard)
53
67
  end
54
68
 
69
+ get "/metrics" do
70
+ q = Sidekiq::Metrics::Query.new
71
+ @period = h((params[:period] || "")[0..1])
72
+ @periods = METRICS_PERIODS
73
+ minutes = @periods.fetch(@period, @periods.values.first)
74
+ @query_result = q.top_jobs(minutes: minutes)
75
+ erb(:metrics)
76
+ end
77
+
78
+ get "/metrics/:name" do
79
+ @name = route_params[:name]
80
+ @period = h((params[:period] || "")[0..1])
81
+ q = Sidekiq::Metrics::Query.new
82
+ @periods = METRICS_PERIODS
83
+ minutes = @periods.fetch(@period, @periods.values.first)
84
+ @query_result = q.for_job(@name, minutes: minutes)
85
+ erb(:metrics_for_job)
86
+ end
87
+
55
88
  get "/busy" do
89
+ @count = (params["count"] || 100).to_i
90
+ (@current_page, @total_size, @workset) = page_items(workset, params["page"], @count)
91
+
56
92
  erb(:busy)
57
93
  end
58
94
 
59
95
  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']
96
+ if params["identity"]
97
+ pro = Sidekiq::ProcessSet[params["identity"]]
98
+
99
+ pro.quiet! if params["quiet"]
100
+ pro.stop! if params["stop"]
64
101
  else
65
102
  processes.each do |pro|
66
- pro.quiet! if params['quiet']
67
- pro.stop! if params['stop']
103
+ next if pro.embedded?
104
+
105
+ pro.quiet! if params["quiet"]
106
+ pro.stop! if params["stop"]
68
107
  end
69
108
  end
70
109
 
@@ -77,42 +116,53 @@ module Sidekiq
77
116
  erb(:queues)
78
117
  end
79
118
 
119
+ QUEUE_NAME = /\A[a-z_:.\-0-9]+\z/i
120
+
80
121
  get "/queues/:name" do
81
122
  @name = route_params[:name]
82
123
 
83
- halt(404) unless @name
124
+ halt(404) if !@name || @name !~ QUEUE_NAME
84
125
 
85
- @count = (params['count'] || 25).to_i
126
+ @count = (params["count"] || 25).to_i
86
127
  @queue = Sidekiq::Queue.new(@name)
87
- (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count)
88
- @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) }
128
+ (@current_page, @total_size, @jobs) = page("queue:#{@name}", params["page"], @count, reverse: params["direction"] == "asc")
129
+ @jobs = @jobs.map { |msg| Sidekiq::JobRecord.new(msg, @name) }
89
130
 
90
131
  erb(:queue)
91
132
  end
92
133
 
93
134
  post "/queues/:name" do
94
- Sidekiq::Queue.new(route_params[:name]).clear
135
+ queue = Sidekiq::Queue.new(route_params[:name])
136
+
137
+ if Sidekiq.pro? && params["pause"]
138
+ queue.pause!
139
+ elsif Sidekiq.pro? && params["unpause"]
140
+ queue.unpause!
141
+ else
142
+ queue.clear
143
+ end
95
144
 
96
145
  redirect "#{root_path}queues"
97
146
  end
98
147
 
99
148
  post "/queues/:name/delete" do
100
149
  name = route_params[:name]
101
- Sidekiq::Job.new(params['key_val'], name).delete
150
+ Sidekiq::JobRecord.new(params["key_val"], name).delete
102
151
 
103
152
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
104
153
  end
105
154
 
106
- get '/morgue' do
107
- @count = (params['count'] || 25).to_i
108
- (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true)
155
+ get "/morgue" do
156
+ @count = (params["count"] || 25).to_i
157
+ (@current_page, @total_size, @dead) = page("dead", params["page"], @count, reverse: true)
109
158
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
110
159
 
111
160
  erb(:morgue)
112
161
  end
113
162
 
114
163
  get "/morgue/:key" do
115
- halt(404) unless key = route_params[:key]
164
+ key = route_params[:key]
165
+ halt(404) unless key
116
166
 
117
167
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
118
168
 
@@ -123,10 +173,10 @@ module Sidekiq
123
173
  end
124
174
  end
125
175
 
126
- post '/morgue' do
127
- redirect(request.path) unless params['key']
176
+ post "/morgue" do
177
+ redirect(request.path) unless params["key"]
128
178
 
129
- params['key'].each do |key|
179
+ params["key"].each do |key|
130
180
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
131
181
  retry_or_delete_or_kill job, params if job
132
182
  end
@@ -147,7 +197,8 @@ module Sidekiq
147
197
  end
148
198
 
149
199
  post "/morgue/:key" do
150
- halt(404) unless key = route_params[:key]
200
+ key = route_params[:key]
201
+ halt(404) unless key
151
202
 
152
203
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
153
204
  retry_or_delete_or_kill job, params if job
@@ -155,9 +206,9 @@ module Sidekiq
155
206
  redirect_with_query("#{root_path}morgue")
156
207
  end
157
208
 
158
- get '/retries' do
159
- @count = (params['count'] || 25).to_i
160
- (@current_page, @total_size, @retries) = page("retry", params['page'], @count)
209
+ get "/retries" do
210
+ @count = (params["count"] || 25).to_i
211
+ (@current_page, @total_size, @retries) = page("retry", params["page"], @count)
161
212
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
162
213
 
163
214
  erb(:retries)
@@ -173,10 +224,10 @@ module Sidekiq
173
224
  end
174
225
  end
175
226
 
176
- post '/retries' do
177
- redirect(request.path) unless params['key']
227
+ post "/retries" do
228
+ redirect(request.path) unless params["key"]
178
229
 
179
- params['key'].each do |key|
230
+ params["key"].each do |key|
180
231
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
181
232
  retry_or_delete_or_kill job, params if job
182
233
  end
@@ -210,9 +261,9 @@ module Sidekiq
210
261
  redirect_with_query("#{root_path}retries")
211
262
  end
212
263
 
213
- get '/scheduled' do
214
- @count = (params['count'] || 25).to_i
215
- (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count)
264
+ get "/scheduled" do
265
+ @count = (params["count"] || 25).to_i
266
+ (@current_page, @total_size, @scheduled) = page("schedule", params["page"], @count)
216
267
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
217
268
 
218
269
  erb(:scheduled)
@@ -228,10 +279,10 @@ module Sidekiq
228
279
  end
229
280
  end
230
281
 
231
- post '/scheduled' do
232
- redirect(request.path) unless params['key']
282
+ post "/scheduled" do
283
+ redirect(request.path) unless params["key"]
233
284
 
234
- params['key'].each do |key|
285
+ params["key"].each do |key|
235
286
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
236
287
  delete_or_add_queue job, params if job
237
288
  end
@@ -240,7 +291,8 @@ module Sidekiq
240
291
  end
241
292
 
242
293
  post "/scheduled/:key" do
243
- halt(404) unless key = route_params[:key]
294
+ key = route_params[:key]
295
+ halt(404) unless key
244
296
 
245
297
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
246
298
  delete_or_add_queue job, params if job
@@ -248,23 +300,23 @@ module Sidekiq
248
300
  redirect_with_query("#{root_path}scheduled")
249
301
  end
250
302
 
251
- get '/dashboard/stats' do
303
+ get "/dashboard/stats" do
252
304
  redirect "#{root_path}stats"
253
305
  end
254
306
 
255
- get '/stats' do
307
+ get "/stats" do
256
308
  sidekiq_stats = Sidekiq::Stats.new
257
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
309
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
258
310
  json(
259
311
  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,
312
+ processed: sidekiq_stats.processed,
313
+ failed: sidekiq_stats.failed,
314
+ busy: sidekiq_stats.workers_size,
315
+ processes: sidekiq_stats.processes_size,
316
+ enqueued: sidekiq_stats.enqueued,
317
+ scheduled: sidekiq_stats.scheduled_size,
318
+ retries: sidekiq_stats.retry_size,
319
+ dead: sidekiq_stats.dead_size,
268
320
  default_latency: sidekiq_stats.default_queue_latency
269
321
  },
270
322
  redis: redis_stats,
@@ -272,60 +324,52 @@ module Sidekiq
272
324
  )
273
325
  end
274
326
 
275
- get '/stats/queues' do
276
- json Sidekiq::Stats::Queues.new.lengths
327
+ get "/stats/queues" do
328
+ json Sidekiq::Stats.new.queues
277
329
  end
278
330
 
279
331
  def call(env)
280
332
  action = self.class.match(env)
281
- return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
333
+ return [404, {"content-type" => "text/plain", "x-cascade" => "pass"}, ["Not Found"]] unless action
282
334
 
335
+ app = @klass
283
336
  resp = catch(:halt) do
284
- app = @klass
285
337
  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
338
+ action.instance_exec env, &action.block
339
+ ensure
340
+ self.class.run_afters(app, action)
293
341
  end
294
342
 
295
- resp = case resp
343
+ case resp
296
344
  when Array
345
+ # redirects go here
297
346
  resp
298
347
  else
348
+ # rendered content goes here
299
349
  headers = {
300
- "Content-Type" => "text/html",
301
- "Cache-Control" => "no-cache",
302
- "Content-Language" => action.locale,
303
- "Content-Security-Policy" => CSP_HEADER
350
+ "content-type" => "text/html",
351
+ "cache-control" => "private, no-store",
352
+ "content-language" => action.locale,
353
+ "content-security-policy" => CSP_HEADER
304
354
  }
305
-
355
+ # we'll let Rack calculate Content-Length for us.
306
356
  [200, headers, [resp]]
307
357
  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
358
  end
315
359
 
316
- def self.helpers(mod=nil, &block)
317
- if block_given?
360
+ def self.helpers(mod = nil, &block)
361
+ if block
318
362
  WebAction.class_eval(&block)
319
363
  else
320
364
  WebAction.send(:include, mod)
321
365
  end
322
366
  end
323
367
 
324
- def self.before(path=nil, &block)
368
+ def self.before(path = nil, &block)
325
369
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
326
370
  end
327
371
 
328
- def self.after(path=nil, &block)
372
+ def self.after(path = nil, &block)
329
373
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
330
374
  end
331
375
 
@@ -338,8 +382,8 @@ module Sidekiq
338
382
  end
339
383
 
340
384
  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) }
385
+ hooks.select { |p, _| !p || p =~ action.env[WebRouter::PATH_INFO] }
386
+ .each { |_, b| action.instance_exec(action.env, app, &b) }
343
387
  end
344
388
 
345
389
  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..]
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