sidekiq 5.2.10 → 6.5.12

Sign up to get free protection for your applications and to get access to all the features.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +422 -1
  3. data/LICENSE +3 -3
  4. data/README.md +24 -35
  5. data/bin/sidekiq +27 -3
  6. data/bin/sidekiqload +79 -67
  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 +527 -310
  13. data/lib/sidekiq/cli.rb +204 -208
  14. data/lib/sidekiq/client.rb +78 -82
  15. data/lib/sidekiq/component.rb +65 -0
  16. data/lib/sidekiq/delay.rb +8 -7
  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 +7 -5
  21. data/lib/sidekiq/fetch.rb +50 -40
  22. data/lib/sidekiq/job.rb +13 -0
  23. data/lib/sidekiq/job_logger.rb +33 -7
  24. data/lib/sidekiq/job_retry.rb +126 -106
  25. data/lib/sidekiq/job_util.rb +71 -0
  26. data/lib/sidekiq/launcher.rb +177 -83
  27. data/lib/sidekiq/logger.rb +156 -0
  28. data/lib/sidekiq/manager.rb +40 -41
  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 +28 -16
  39. data/lib/sidekiq/processor.rb +104 -97
  40. data/lib/sidekiq/rails.rb +46 -37
  41. data/lib/sidekiq/redis_client_adapter.rb +154 -0
  42. data/lib/sidekiq/redis_connection.rb +108 -77
  43. data/lib/sidekiq/ring_buffer.rb +29 -0
  44. data/lib/sidekiq/scheduled.rb +105 -42
  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 +68 -58
  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 -11
  52. data/lib/sidekiq/web/application.rb +103 -77
  53. data/lib/sidekiq/web/csrf_protection.rb +180 -0
  54. data/lib/sidekiq/web/helpers.rb +125 -95
  55. data/lib/sidekiq/web/router.rb +23 -19
  56. data/lib/sidekiq/web.rb +65 -105
  57. data/lib/sidekiq/worker.rb +259 -109
  58. data/lib/sidekiq.rb +170 -62
  59. data/sidekiq.gemspec +23 -16
  60. data/web/assets/images/apple-touch-icon.png +0 -0
  61. data/web/assets/javascripts/application.js +113 -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 +53 -89
  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 +0 -4
  69. data/web/assets/stylesheets/application.css +88 -233
  70. data/web/locales/ar.yml +8 -2
  71. data/web/locales/de.yml +14 -2
  72. data/web/locales/el.yml +43 -19
  73. data/web/locales/en.yml +13 -1
  74. data/web/locales/es.yml +18 -2
  75. data/web/locales/fr.yml +10 -3
  76. data/web/locales/ja.yml +14 -1
  77. data/web/locales/lt.yml +83 -0
  78. data/web/locales/pl.yml +4 -4
  79. data/web/locales/pt-br.yml +27 -9
  80. data/web/locales/ru.yml +4 -0
  81. data/web/locales/vi.yml +83 -0
  82. data/web/locales/zh-cn.yml +36 -11
  83. data/web/locales/zh-tw.yml +32 -7
  84. data/web/views/_footer.erb +1 -1
  85. data/web/views/_job_info.erb +3 -2
  86. data/web/views/_nav.erb +1 -1
  87. data/web/views/_poll_link.erb +2 -5
  88. data/web/views/_summary.erb +7 -7
  89. data/web/views/busy.erb +61 -22
  90. data/web/views/dashboard.erb +23 -14
  91. data/web/views/dead.erb +3 -3
  92. data/web/views/layout.erb +3 -1
  93. data/web/views/metrics.erb +69 -0
  94. data/web/views/metrics_for_job.erb +87 -0
  95. data/web/views/morgue.erb +9 -6
  96. data/web/views/queue.erb +23 -10
  97. data/web/views/queues.erb +10 -2
  98. data/web/views/retries.erb +11 -8
  99. data/web/views/retry.erb +3 -3
  100. data/web/views/scheduled.erb +5 -2
  101. metadata +58 -63
  102. data/.circleci/config.yml +0 -61
  103. data/.github/contributing.md +0 -32
  104. data/.github/issue_template.md +0 -11
  105. data/.gitignore +0 -15
  106. data/.travis.yml +0 -11
  107. data/3.0-Upgrade.md +0 -70
  108. data/4.0-Upgrade.md +0 -53
  109. data/5.0-Upgrade.md +0 -56
  110. data/COMM-LICENSE +0 -97
  111. data/Ent-Changes.md +0 -238
  112. data/Gemfile +0 -19
  113. data/Pro-2.0-Upgrade.md +0 -138
  114. data/Pro-3.0-Upgrade.md +0 -44
  115. data/Pro-4.0-Upgrade.md +0 -35
  116. data/Pro-Changes.md +0 -759
  117. data/Rakefile +0 -9
  118. data/bin/sidekiqctl +0 -20
  119. data/code_of_conduct.md +0 -50
  120. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  121. data/lib/sidekiq/core_ext.rb +0 -1
  122. data/lib/sidekiq/ctl.rb +0 -221
  123. data/lib/sidekiq/exception_handler.rb +0 -29
  124. data/lib/sidekiq/logging.rb +0 -122
  125. data/lib/sidekiq/middleware/server/active_record.rb +0 -23
  126. data/lib/sidekiq/util.rb +0 -66
@@ -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,8 +41,15 @@ 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 }
52
+ @redis_info = redis_info.select { |k, v| REDIS_KEYS.include? k }
48
53
  days = (params["days"] || 30).to_i
49
54
  return halt(401) if days < 1 || days > 180
50
55
 
@@ -55,19 +60,35 @@ module Sidekiq
55
60
  erb(:dashboard)
56
61
  end
57
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
+
58
76
  get "/busy" do
77
+ @count = (params["count"] || 100).to_i
78
+ (@current_page, @total_size, @workset) = page_items(workset, params["page"], @count)
79
+
59
80
  erb(:busy)
60
81
  end
61
82
 
62
83
  post "/busy" do
63
- if params['identity']
64
- p = Sidekiq::Process.new('identity' => params['identity'])
65
- p.quiet! if params['quiet']
66
- 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"]
67
88
  else
68
89
  processes.each do |pro|
69
- pro.quiet! if params['quiet']
70
- pro.stop! if params['stop']
90
+ pro.quiet! if params["quiet"]
91
+ pro.stop! if params["stop"]
71
92
  end
72
93
  end
73
94
 
@@ -80,42 +101,53 @@ module Sidekiq
80
101
  erb(:queues)
81
102
  end
82
103
 
104
+ QUEUE_NAME = /\A[a-z_:.\-0-9]+\z/i
105
+
83
106
  get "/queues/:name" do
84
107
  @name = route_params[:name]
85
108
 
86
- halt(404) unless @name
109
+ halt(404) if !@name || @name !~ QUEUE_NAME
87
110
 
88
- @count = (params['count'] || 25).to_i
111
+ @count = (params["count"] || 25).to_i
89
112
  @queue = Sidekiq::Queue.new(@name)
90
- (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count)
91
- @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) }
92
115
 
93
116
  erb(:queue)
94
117
  end
95
118
 
96
119
  post "/queues/:name" do
97
- 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
98
129
 
99
130
  redirect "#{root_path}queues"
100
131
  end
101
132
 
102
133
  post "/queues/:name/delete" do
103
134
  name = route_params[:name]
104
- Sidekiq::Job.new(params['key_val'], name).delete
135
+ Sidekiq::JobRecord.new(params["key_val"], name).delete
105
136
 
106
137
  redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
107
138
  end
108
139
 
109
- get '/morgue' do
110
- @count = (params['count'] || 25).to_i
111
- (@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)
112
143
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
113
144
 
114
145
  erb(:morgue)
115
146
  end
116
147
 
117
148
  get "/morgue/:key" do
118
- halt(404) unless key = route_params[:key]
149
+ key = route_params[:key]
150
+ halt(404) unless key
119
151
 
120
152
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
121
153
 
@@ -126,10 +158,10 @@ module Sidekiq
126
158
  end
127
159
  end
128
160
 
129
- post '/morgue' do
130
- redirect(request.path) unless params['key']
161
+ post "/morgue" do
162
+ redirect(request.path) unless params["key"]
131
163
 
132
- params['key'].each do |key|
164
+ params["key"].each do |key|
133
165
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
134
166
  retry_or_delete_or_kill job, params if job
135
167
  end
@@ -150,7 +182,8 @@ module Sidekiq
150
182
  end
151
183
 
152
184
  post "/morgue/:key" do
153
- halt(404) unless key = route_params[:key]
185
+ key = route_params[:key]
186
+ halt(404) unless key
154
187
 
155
188
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
156
189
  retry_or_delete_or_kill job, params if job
@@ -158,9 +191,9 @@ module Sidekiq
158
191
  redirect_with_query("#{root_path}morgue")
159
192
  end
160
193
 
161
- get '/retries' do
162
- @count = (params['count'] || 25).to_i
163
- (@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)
164
197
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
165
198
 
166
199
  erb(:retries)
@@ -176,10 +209,10 @@ module Sidekiq
176
209
  end
177
210
  end
178
211
 
179
- post '/retries' do
180
- redirect(request.path) unless params['key']
212
+ post "/retries" do
213
+ redirect(request.path) unless params["key"]
181
214
 
182
- params['key'].each do |key|
215
+ params["key"].each do |key|
183
216
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
184
217
  retry_or_delete_or_kill job, params if job
185
218
  end
@@ -213,9 +246,9 @@ module Sidekiq
213
246
  redirect_with_query("#{root_path}retries")
214
247
  end
215
248
 
216
- get '/scheduled' do
217
- @count = (params['count'] || 25).to_i
218
- (@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)
219
252
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
220
253
 
221
254
  erb(:scheduled)
@@ -231,10 +264,10 @@ module Sidekiq
231
264
  end
232
265
  end
233
266
 
234
- post '/scheduled' do
235
- redirect(request.path) unless params['key']
267
+ post "/scheduled" do
268
+ redirect(request.path) unless params["key"]
236
269
 
237
- params['key'].each do |key|
270
+ params["key"].each do |key|
238
271
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
239
272
  delete_or_add_queue job, params if job
240
273
  end
@@ -243,7 +276,8 @@ module Sidekiq
243
276
  end
244
277
 
245
278
  post "/scheduled/:key" do
246
- halt(404) unless key = route_params[:key]
279
+ key = route_params[:key]
280
+ halt(404) unless key
247
281
 
248
282
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
249
283
  delete_or_add_queue job, params if job
@@ -251,23 +285,23 @@ module Sidekiq
251
285
  redirect_with_query("#{root_path}scheduled")
252
286
  end
253
287
 
254
- get '/dashboard/stats' do
288
+ get "/dashboard/stats" do
255
289
  redirect "#{root_path}stats"
256
290
  end
257
291
 
258
- get '/stats' do
292
+ get "/stats" do
259
293
  sidekiq_stats = Sidekiq::Stats.new
260
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
294
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
261
295
  json(
262
296
  sidekiq: {
263
- processed: sidekiq_stats.processed,
264
- failed: sidekiq_stats.failed,
265
- busy: sidekiq_stats.workers_size,
266
- processes: sidekiq_stats.processes_size,
267
- enqueued: sidekiq_stats.enqueued,
268
- scheduled: sidekiq_stats.scheduled_size,
269
- retries: sidekiq_stats.retry_size,
270
- 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,
271
305
  default_latency: sidekiq_stats.default_queue_latency
272
306
  },
273
307
  redis: redis_stats,
@@ -275,60 +309,52 @@ module Sidekiq
275
309
  )
276
310
  end
277
311
 
278
- get '/stats/queues' do
312
+ get "/stats/queues" do
279
313
  json Sidekiq::Stats::Queues.new.lengths
280
314
  end
281
315
 
282
316
  def call(env)
283
317
  action = self.class.match(env)
284
- 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
285
319
 
320
+ app = @klass
286
321
  resp = catch(:halt) do
287
- app = @klass
288
322
  self.class.run_befores(app, action)
289
- begin
290
- resp = action.instance_exec env, &action.block
291
- ensure
292
- self.class.run_afters(app, action)
293
- end
294
-
295
- resp
323
+ action.instance_exec env, &action.block
324
+ ensure
325
+ self.class.run_afters(app, action)
296
326
  end
297
327
 
298
- resp = case resp
328
+ case resp
299
329
  when Array
330
+ # redirects go here
300
331
  resp
301
332
  else
333
+ # rendered content goes here
302
334
  headers = {
303
- "Content-Type" => "text/html",
304
- "Cache-Control" => "no-cache",
305
- "Content-Language" => action.locale,
306
- "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
307
339
  }
308
-
340
+ # we'll let Rack calculate Content-Length for us.
309
341
  [200, headers, [resp]]
310
342
  end
311
-
312
- resp[1] = resp[1].dup
313
-
314
- resp[1][CONTENT_LENGTH] = resp[2].inject(0) { |l, p| l + p.bytesize }.to_s
315
-
316
- resp
317
343
  end
318
344
 
319
- def self.helpers(mod=nil, &block)
320
- if block_given?
345
+ def self.helpers(mod = nil, &block)
346
+ if block
321
347
  WebAction.class_eval(&block)
322
348
  else
323
349
  WebAction.send(:include, mod)
324
350
  end
325
351
  end
326
352
 
327
- def self.before(path=nil, &block)
353
+ def self.before(path = nil, &block)
328
354
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
329
355
  end
330
356
 
331
- def self.after(path=nil, &block)
357
+ def self.after(path = nil, &block)
332
358
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
333
359
  end
334
360
 
@@ -341,8 +367,8 @@ module Sidekiq
341
367
  end
342
368
 
343
369
  def self.run_hooks(hooks, app, action)
344
- hooks.select { |p,_| !p || p =~ action.env[WebRouter::PATH_INFO] }.
345
- 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) }
346
372
  end
347
373
 
348
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..-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