sidekiq 5.2.7 → 7.0.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +465 -5
  3. data/LICENSE.txt +9 -0
  4. data/README.md +32 -42
  5. data/bin/sidekiq +22 -3
  6. data/bin/sidekiqload +80 -77
  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/{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 +506 -351
  13. data/lib/sidekiq/capsule.rb +110 -0
  14. data/lib/sidekiq/cli.rb +202 -226
  15. data/lib/sidekiq/client.rb +104 -95
  16. data/lib/sidekiq/component.rb +66 -0
  17. data/lib/sidekiq/config.rb +270 -0
  18. data/lib/sidekiq/deploy.rb +62 -0
  19. data/lib/sidekiq/embedded.rb +61 -0
  20. data/lib/sidekiq/fetch.rb +49 -40
  21. data/lib/sidekiq/job.rb +378 -0
  22. data/lib/sidekiq/job_logger.rb +33 -7
  23. data/lib/sidekiq/job_retry.rb +127 -107
  24. data/lib/sidekiq/job_util.rb +71 -0
  25. data/lib/sidekiq/launcher.rb +197 -103
  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 +134 -0
  31. data/lib/sidekiq/middleware/chain.rb +104 -50
  32. data/lib/sidekiq/middleware/current_attributes.rb +58 -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 +148 -0
  36. data/lib/sidekiq/paginator.rb +28 -16
  37. data/lib/sidekiq/processor.rb +105 -107
  38. data/lib/sidekiq/rails.rb +41 -37
  39. data/lib/sidekiq/redis_client_adapter.rb +115 -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 +108 -79
  51. data/lib/sidekiq/web/csrf_protection.rb +180 -0
  52. data/lib/sidekiq/web/helpers.rb +128 -105
  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 +45 -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 +236 -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 +52 -52
  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 +69 -62
  78. data/web/locales/he.yml +65 -64
  79. data/web/locales/hi.yml +59 -59
  80. data/web/locales/it.yml +53 -53
  81. data/web/locales/ja.yml +73 -64
  82. data/web/locales/ko.yml +52 -52
  83. data/web/locales/lt.yml +83 -0
  84. data/web/locales/nb.yml +61 -61
  85. data/web/locales/nl.yml +52 -52
  86. data/web/locales/pl.yml +45 -45
  87. data/web/locales/pt-br.yml +63 -55
  88. data/web/locales/pt.yml +51 -51
  89. data/web/locales/ru.yml +68 -63
  90. data/web/locales/sv.yml +53 -53
  91. data/web/locales/ta.yml +60 -60
  92. data/web/locales/uk.yml +62 -61
  93. data/web/locales/ur.yml +64 -64
  94. data/web/locales/vi.yml +83 -0
  95. data/web/locales/zh-cn.yml +43 -16
  96. data/web/locales/zh-tw.yml +42 -8
  97. data/web/views/_footer.erb +6 -3
  98. data/web/views/_job_info.erb +19 -2
  99. data/web/views/_nav.erb +1 -1
  100. data/web/views/_poll_link.erb +3 -6
  101. data/web/views/_summary.erb +7 -7
  102. data/web/views/busy.erb +74 -22
  103. data/web/views/dashboard.erb +58 -18
  104. data/web/views/dead.erb +3 -3
  105. data/web/views/layout.erb +3 -1
  106. data/web/views/metrics.erb +80 -0
  107. data/web/views/metrics_for_job.erb +69 -0
  108. data/web/views/morgue.erb +10 -7
  109. data/web/views/queue.erb +23 -10
  110. data/web/views/queues.erb +10 -2
  111. data/web/views/retries.erb +12 -9
  112. data/web/views/retry.erb +3 -3
  113. data/web/views/scheduled.erb +6 -3
  114. metadata +84 -69
  115. data/.circleci/config.yml +0 -61
  116. data/.github/contributing.md +0 -32
  117. data/.github/issue_template.md +0 -11
  118. data/.gitignore +0 -15
  119. data/.travis.yml +0 -11
  120. data/3.0-Upgrade.md +0 -70
  121. data/4.0-Upgrade.md +0 -53
  122. data/5.0-Upgrade.md +0 -56
  123. data/COMM-LICENSE +0 -97
  124. data/Ent-Changes.md +0 -238
  125. data/Gemfile +0 -23
  126. data/LICENSE +0 -9
  127. data/Pro-2.0-Upgrade.md +0 -138
  128. data/Pro-3.0-Upgrade.md +0 -44
  129. data/Pro-4.0-Upgrade.md +0 -35
  130. data/Pro-Changes.md +0 -759
  131. data/Rakefile +0 -9
  132. data/bin/sidekiqctl +0 -20
  133. data/code_of_conduct.md +0 -50
  134. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  135. data/lib/sidekiq/core_ext.rb +0 -1
  136. data/lib/sidekiq/ctl.rb +0 -221
  137. data/lib/sidekiq/delay.rb +0 -42
  138. data/lib/sidekiq/exception_handler.rb +0 -29
  139. data/lib/sidekiq/extensions/action_mailer.rb +0 -57
  140. data/lib/sidekiq/extensions/active_record.rb +0 -40
  141. data/lib/sidekiq/extensions/class_methods.rb +0 -40
  142. data/lib/sidekiq/extensions/generic_proxy.rb +0 -31
  143. data/lib/sidekiq/logging.rb +0 -122
  144. data/lib/sidekiq/middleware/server/active_record.rb +0 -23
  145. data/lib/sidekiq/util.rb +0 -66
  146. 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,7 @@ 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
25
23
 
26
24
  def initialize(klass)
27
25
  @klass = klass
@@ -43,28 +41,54 @@ module Sidekiq
43
41
  # nothing, backwards compatibility
44
42
  end
45
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
+
46
51
  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)
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)
49
57
  @processed_history = stats_history.processed
50
58
  @failed_history = stats_history.failed
51
59
 
52
60
  erb(:dashboard)
53
61
  end
54
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
+
55
76
  get "/busy" do
77
+ @count = (params["count"] || 100).to_i
78
+ (@current_page, @total_size, @workset) = page_items(workset, params["page"], @count)
79
+
56
80
  erb(:busy)
57
81
  end
58
82
 
59
83
  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']
84
+ if params["identity"]
85
+ p = Sidekiq::Process.new("identity" => params["identity"])
86
+ p.quiet! if params["quiet"]
87
+ p.stop! if params["stop"]
64
88
  else
65
89
  processes.each do |pro|
66
- pro.quiet! if params['quiet']
67
- pro.stop! if params['stop']
90
+ pro.quiet! if params["quiet"]
91
+ pro.stop! if params["stop"]
68
92
  end
69
93
  end
70
94
 
@@ -77,42 +101,53 @@ module Sidekiq
77
101
  erb(:queues)
78
102
  end
79
103
 
104
+ QUEUE_NAME = /\A[a-z_:.\-0-9]+\z/i
105
+
80
106
  get "/queues/:name" do
81
107
  @name = route_params[:name]
82
108
 
83
- halt(404) unless @name
109
+ halt(404) if !@name || @name !~ QUEUE_NAME
84
110
 
85
- @count = (params['count'] || 25).to_i
111
+ @count = (params["count"] || 25).to_i
86
112
  @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) }
113
+ (@current_page, @total_size, @jobs) = page("queue:#{@name}", params["page"], @count, reverse: params["direction"] == "asc")
114
+ @jobs = @jobs.map { |msg| Sidekiq::JobRecord.new(msg, @name) }
89
115
 
90
116
  erb(:queue)
91
117
  end
92
118
 
93
119
  post "/queues/:name" do
94
- Sidekiq::Queue.new(route_params[:name]).clear
120
+ queue = Sidekiq::Queue.new(route_params[:name])
121
+
122
+ if Sidekiq.pro? && params["pause"]
123
+ queue.pause!
124
+ elsif Sidekiq.pro? && params["unpause"]
125
+ queue.unpause!
126
+ else
127
+ queue.clear
128
+ end
95
129
 
96
130
  redirect "#{root_path}queues"
97
131
  end
98
132
 
99
133
  post "/queues/:name/delete" do
100
134
  name = route_params[:name]
101
- Sidekiq::Job.new(params['key_val'], name).delete
135
+ Sidekiq::JobRecord.new(params["key_val"], name).delete
102
136
 
103
137
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
104
138
  end
105
139
 
106
- get '/morgue' do
107
- @count = (params['count'] || 25).to_i
108
- (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true)
140
+ get "/morgue" do
141
+ @count = (params["count"] || 25).to_i
142
+ (@current_page, @total_size, @dead) = page("dead", params["page"], @count, reverse: true)
109
143
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
110
144
 
111
145
  erb(:morgue)
112
146
  end
113
147
 
114
148
  get "/morgue/:key" do
115
- halt(404) unless key = route_params[:key]
149
+ key = route_params[:key]
150
+ halt(404) unless key
116
151
 
117
152
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
118
153
 
@@ -123,10 +158,10 @@ module Sidekiq
123
158
  end
124
159
  end
125
160
 
126
- post '/morgue' do
127
- redirect(request.path) unless params['key']
161
+ post "/morgue" do
162
+ redirect(request.path) unless params["key"]
128
163
 
129
- params['key'].each do |key|
164
+ params["key"].each do |key|
130
165
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
131
166
  retry_or_delete_or_kill job, params if job
132
167
  end
@@ -147,7 +182,8 @@ module Sidekiq
147
182
  end
148
183
 
149
184
  post "/morgue/:key" do
150
- halt(404) unless key = route_params[:key]
185
+ key = route_params[:key]
186
+ halt(404) unless key
151
187
 
152
188
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
153
189
  retry_or_delete_or_kill job, params if job
@@ -155,9 +191,9 @@ module Sidekiq
155
191
  redirect_with_query("#{root_path}morgue")
156
192
  end
157
193
 
158
- get '/retries' do
159
- @count = (params['count'] || 25).to_i
160
- (@current_page, @total_size, @retries) = page("retry", params['page'], @count)
194
+ get "/retries" do
195
+ @count = (params["count"] || 25).to_i
196
+ (@current_page, @total_size, @retries) = page("retry", params["page"], @count)
161
197
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
162
198
 
163
199
  erb(:retries)
@@ -173,10 +209,10 @@ module Sidekiq
173
209
  end
174
210
  end
175
211
 
176
- post '/retries' do
177
- redirect(request.path) unless params['key']
212
+ post "/retries" do
213
+ redirect(request.path) unless params["key"]
178
214
 
179
- params['key'].each do |key|
215
+ params["key"].each do |key|
180
216
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
181
217
  retry_or_delete_or_kill job, params if job
182
218
  end
@@ -210,9 +246,9 @@ module Sidekiq
210
246
  redirect_with_query("#{root_path}retries")
211
247
  end
212
248
 
213
- get '/scheduled' do
214
- @count = (params['count'] || 25).to_i
215
- (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count)
249
+ get "/scheduled" do
250
+ @count = (params["count"] || 25).to_i
251
+ (@current_page, @total_size, @scheduled) = page("schedule", params["page"], @count)
216
252
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
217
253
 
218
254
  erb(:scheduled)
@@ -228,10 +264,10 @@ module Sidekiq
228
264
  end
229
265
  end
230
266
 
231
- post '/scheduled' do
232
- redirect(request.path) unless params['key']
267
+ post "/scheduled" do
268
+ redirect(request.path) unless params["key"]
233
269
 
234
- params['key'].each do |key|
270
+ params["key"].each do |key|
235
271
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
236
272
  delete_or_add_queue job, params if job
237
273
  end
@@ -240,7 +276,8 @@ module Sidekiq
240
276
  end
241
277
 
242
278
  post "/scheduled/:key" do
243
- halt(404) unless key = route_params[:key]
279
+ key = route_params[:key]
280
+ halt(404) unless key
244
281
 
245
282
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
246
283
  delete_or_add_queue job, params if job
@@ -248,23 +285,23 @@ module Sidekiq
248
285
  redirect_with_query("#{root_path}scheduled")
249
286
  end
250
287
 
251
- get '/dashboard/stats' do
288
+ get "/dashboard/stats" do
252
289
  redirect "#{root_path}stats"
253
290
  end
254
291
 
255
- get '/stats' do
292
+ get "/stats" do
256
293
  sidekiq_stats = Sidekiq::Stats.new
257
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
294
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
258
295
  json(
259
296
  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,
297
+ processed: sidekiq_stats.processed,
298
+ failed: sidekiq_stats.failed,
299
+ busy: sidekiq_stats.workers_size,
300
+ processes: sidekiq_stats.processes_size,
301
+ enqueued: sidekiq_stats.enqueued,
302
+ scheduled: sidekiq_stats.scheduled_size,
303
+ retries: sidekiq_stats.retry_size,
304
+ dead: sidekiq_stats.dead_size,
268
305
  default_latency: sidekiq_stats.default_queue_latency
269
306
  },
270
307
  redis: redis_stats,
@@ -272,60 +309,52 @@ module Sidekiq
272
309
  )
273
310
  end
274
311
 
275
- get '/stats/queues' do
276
- json Sidekiq::Stats::Queues.new.lengths
312
+ get "/stats/queues" do
313
+ json Sidekiq::Stats.new.queues
277
314
  end
278
315
 
279
316
  def call(env)
280
317
  action = self.class.match(env)
281
- return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
318
+ return [404, {"content-type" => "text/plain", "x-cascade" => "pass"}, ["Not Found"]] unless action
282
319
 
320
+ app = @klass
283
321
  resp = catch(:halt) do
284
- app = @klass
285
322
  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
323
+ action.instance_exec env, &action.block
324
+ ensure
325
+ self.class.run_afters(app, action)
293
326
  end
294
327
 
295
- resp = case resp
328
+ case resp
296
329
  when Array
330
+ # redirects go here
297
331
  resp
298
332
  else
333
+ # rendered content goes here
299
334
  headers = {
300
- "Content-Type" => "text/html",
301
- "Cache-Control" => "no-cache",
302
- "Content-Language" => action.locale,
303
- "Content-Security-Policy" => CSP_HEADER
335
+ "content-type" => "text/html",
336
+ "cache-control" => "private, no-store",
337
+ "content-language" => action.locale,
338
+ "content-security-policy" => CSP_HEADER
304
339
  }
305
-
340
+ # we'll let Rack calculate Content-Length for us.
306
341
  [200, headers, [resp]]
307
342
  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
343
  end
315
344
 
316
- def self.helpers(mod=nil, &block)
317
- if block_given?
345
+ def self.helpers(mod = nil, &block)
346
+ if block
318
347
  WebAction.class_eval(&block)
319
348
  else
320
349
  WebAction.send(:include, mod)
321
350
  end
322
351
  end
323
352
 
324
- def self.before(path=nil, &block)
353
+ def self.before(path = nil, &block)
325
354
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
326
355
  end
327
356
 
328
- def self.after(path=nil, &block)
357
+ def self.after(path = nil, &block)
329
358
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
330
359
  end
331
360
 
@@ -338,8 +367,8 @@ module Sidekiq
338
367
  end
339
368
 
340
369
  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) }
370
+ hooks.select { |p, _| !p || p =~ action.env[WebRouter::PATH_INFO] }
371
+ .each { |_, b| action.instance_exec(action.env, app, &b) }
343
372
  end
344
373
 
345
374
  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