sidekiq 4.2.4 → 6.4.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 (143) hide show
  1. checksums.yaml +5 -5
  2. data/Changes.md +523 -0
  3. data/LICENSE +3 -3
  4. data/README.md +23 -36
  5. data/bin/sidekiq +26 -2
  6. data/bin/sidekiqload +28 -38
  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/job_test.rb.erb +8 -0
  12. data/lib/sidekiq/api.rb +403 -243
  13. data/lib/sidekiq/cli.rb +230 -211
  14. data/lib/sidekiq/client.rb +53 -64
  15. data/lib/sidekiq/delay.rb +43 -0
  16. data/lib/sidekiq/exception_handler.rb +12 -16
  17. data/lib/sidekiq/extensions/action_mailer.rb +15 -24
  18. data/lib/sidekiq/extensions/active_record.rb +15 -12
  19. data/lib/sidekiq/extensions/class_methods.rb +16 -13
  20. data/lib/sidekiq/extensions/generic_proxy.rb +14 -6
  21. data/lib/sidekiq/fetch.rb +39 -31
  22. data/lib/sidekiq/job.rb +13 -0
  23. data/lib/sidekiq/job_logger.rb +63 -0
  24. data/lib/sidekiq/job_retry.rb +261 -0
  25. data/lib/sidekiq/job_util.rb +65 -0
  26. data/lib/sidekiq/launcher.rb +170 -71
  27. data/lib/sidekiq/logger.rb +166 -0
  28. data/lib/sidekiq/manager.rb +21 -26
  29. data/lib/sidekiq/middleware/chain.rb +20 -8
  30. data/lib/sidekiq/middleware/current_attributes.rb +57 -0
  31. data/lib/sidekiq/middleware/i18n.rb +5 -7
  32. data/lib/sidekiq/monitor.rb +133 -0
  33. data/lib/sidekiq/paginator.rb +18 -14
  34. data/lib/sidekiq/processor.rb +161 -70
  35. data/lib/sidekiq/rails.rb +41 -73
  36. data/lib/sidekiq/redis_connection.rb +65 -20
  37. data/lib/sidekiq/scheduled.rb +95 -34
  38. data/lib/sidekiq/sd_notify.rb +149 -0
  39. data/lib/sidekiq/systemd.rb +24 -0
  40. data/lib/sidekiq/testing/inline.rb +2 -1
  41. data/lib/sidekiq/testing.rb +52 -26
  42. data/lib/sidekiq/util.rb +60 -14
  43. data/lib/sidekiq/version.rb +2 -1
  44. data/lib/sidekiq/web/action.rb +15 -15
  45. data/lib/sidekiq/web/application.rb +115 -89
  46. data/lib/sidekiq/web/csrf_protection.rb +180 -0
  47. data/lib/sidekiq/web/helpers.rb +151 -83
  48. data/lib/sidekiq/web/router.rb +27 -19
  49. data/lib/sidekiq/web.rb +65 -109
  50. data/lib/sidekiq/worker.rb +284 -41
  51. data/lib/sidekiq.rb +93 -60
  52. data/sidekiq.gemspec +24 -22
  53. data/web/assets/images/apple-touch-icon.png +0 -0
  54. data/web/assets/javascripts/application.js +83 -64
  55. data/web/assets/javascripts/dashboard.js +81 -85
  56. data/web/assets/stylesheets/application-dark.css +143 -0
  57. data/web/assets/stylesheets/application-rtl.css +242 -0
  58. data/web/assets/stylesheets/application.css +319 -143
  59. data/web/assets/stylesheets/bootstrap-rtl.min.css +9 -0
  60. data/web/assets/stylesheets/bootstrap.css +2 -2
  61. data/web/locales/ar.yml +87 -0
  62. data/web/locales/de.yml +14 -2
  63. data/web/locales/en.yml +8 -1
  64. data/web/locales/es.yml +22 -5
  65. data/web/locales/fa.yml +80 -0
  66. data/web/locales/fr.yml +10 -3
  67. data/web/locales/he.yml +79 -0
  68. data/web/locales/ja.yml +12 -4
  69. data/web/locales/lt.yml +83 -0
  70. data/web/locales/pl.yml +4 -4
  71. data/web/locales/ru.yml +4 -0
  72. data/web/locales/ur.yml +80 -0
  73. data/web/locales/vi.yml +83 -0
  74. data/web/views/_footer.erb +5 -2
  75. data/web/views/_job_info.erb +4 -3
  76. data/web/views/_nav.erb +4 -18
  77. data/web/views/_paging.erb +1 -1
  78. data/web/views/_poll_link.erb +2 -5
  79. data/web/views/_summary.erb +7 -7
  80. data/web/views/busy.erb +60 -22
  81. data/web/views/dashboard.erb +23 -15
  82. data/web/views/dead.erb +3 -3
  83. data/web/views/layout.erb +14 -3
  84. data/web/views/morgue.erb +19 -12
  85. data/web/views/queue.erb +24 -14
  86. data/web/views/queues.erb +14 -4
  87. data/web/views/retries.erb +22 -13
  88. data/web/views/retry.erb +4 -4
  89. data/web/views/scheduled.erb +7 -4
  90. metadata +49 -198
  91. data/.github/contributing.md +0 -32
  92. data/.github/issue_template.md +0 -4
  93. data/.gitignore +0 -12
  94. data/.travis.yml +0 -12
  95. data/3.0-Upgrade.md +0 -70
  96. data/4.0-Upgrade.md +0 -53
  97. data/COMM-LICENSE +0 -95
  98. data/Ent-Changes.md +0 -146
  99. data/Gemfile +0 -29
  100. data/Pro-2.0-Upgrade.md +0 -138
  101. data/Pro-3.0-Upgrade.md +0 -44
  102. data/Pro-Changes.md +0 -585
  103. data/Rakefile +0 -9
  104. data/bin/sidekiqctl +0 -99
  105. data/code_of_conduct.md +0 -50
  106. data/lib/generators/sidekiq/templates/worker_spec.rb.erb +0 -6
  107. data/lib/generators/sidekiq/templates/worker_test.rb.erb +0 -8
  108. data/lib/generators/sidekiq/worker_generator.rb +0 -49
  109. data/lib/sidekiq/core_ext.rb +0 -106
  110. data/lib/sidekiq/logging.rb +0 -106
  111. data/lib/sidekiq/middleware/server/active_record.rb +0 -13
  112. data/lib/sidekiq/middleware/server/logging.rb +0 -40
  113. data/lib/sidekiq/middleware/server/retry_jobs.rb +0 -205
  114. data/test/config.yml +0 -9
  115. data/test/env_based_config.yml +0 -11
  116. data/test/fake_env.rb +0 -1
  117. data/test/fixtures/en.yml +0 -2
  118. data/test/helper.rb +0 -75
  119. data/test/test_actors.rb +0 -138
  120. data/test/test_api.rb +0 -528
  121. data/test/test_cli.rb +0 -418
  122. data/test/test_client.rb +0 -266
  123. data/test/test_exception_handler.rb +0 -56
  124. data/test/test_extensions.rb +0 -127
  125. data/test/test_fetch.rb +0 -50
  126. data/test/test_launcher.rb +0 -95
  127. data/test/test_logging.rb +0 -35
  128. data/test/test_manager.rb +0 -50
  129. data/test/test_middleware.rb +0 -158
  130. data/test/test_processor.rb +0 -235
  131. data/test/test_rails.rb +0 -22
  132. data/test/test_redis_connection.rb +0 -132
  133. data/test/test_retry.rb +0 -326
  134. data/test/test_retry_exhausted.rb +0 -149
  135. data/test/test_scheduled.rb +0 -115
  136. data/test/test_scheduling.rb +0 -58
  137. data/test/test_sidekiq.rb +0 -107
  138. data/test/test_testing.rb +0 -143
  139. data/test/test_testing_fake.rb +0 -357
  140. data/test/test_testing_inline.rb +0 -94
  141. data/test/test_util.rb +0 -13
  142. data/test/test_web.rb +0 -726
  143. data/test/test_web_helpers.rb +0 -54
@@ -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,13 +41,19 @@ module Sidekiq
28
41
  # nothing, backwards compatibility
29
42
  end
30
43
 
31
- get "" do
32
- redirect(root_path)
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
+ ""
33
49
  end
34
50
 
35
51
  get "/" do
36
- @redis_info = redis_info.select{ |k, v| REDIS_KEYS.include? k }
37
- 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)
38
57
  @processed_history = stats_history.processed
39
58
  @failed_history = stats_history.failed
40
59
 
@@ -46,14 +65,14 @@ module Sidekiq
46
65
  end
47
66
 
48
67
  post "/busy" do
49
- if params['identity']
50
- p = Sidekiq::Process.new('identity' => params['identity'])
51
- p.quiet! if params['quiet']
52
- p.stop! if params['stop']
68
+ if params["identity"]
69
+ p = Sidekiq::Process.new("identity" => params["identity"])
70
+ p.quiet! if params["quiet"]
71
+ p.stop! if params["stop"]
53
72
  else
54
73
  processes.each do |pro|
55
- pro.quiet! if params['quiet']
56
- pro.stop! if params['stop']
74
+ pro.quiet! if params["quiet"]
75
+ pro.stop! if params["stop"]
57
76
  end
58
77
  end
59
78
 
@@ -66,42 +85,53 @@ module Sidekiq
66
85
  erb(:queues)
67
86
  end
68
87
 
88
+ QUEUE_NAME = /\A[a-z_:.\-0-9]+\z/i
89
+
69
90
  get "/queues/:name" do
70
91
  @name = route_params[:name]
71
92
 
72
- halt(404) unless @name
93
+ halt(404) if !@name || @name !~ QUEUE_NAME
73
94
 
74
- @count = (params['count'] || 25).to_i
95
+ @count = (params["count"] || 25).to_i
75
96
  @queue = Sidekiq::Queue.new(@name)
76
- (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count)
77
- @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) }
97
+ (@current_page, @total_size, @jobs) = page("queue:#{@name}", params["page"], @count, reverse: params["direction"] == "asc")
98
+ @jobs = @jobs.map { |msg| Sidekiq::JobRecord.new(msg, @name) }
78
99
 
79
100
  erb(:queue)
80
101
  end
81
102
 
82
103
  post "/queues/:name" do
83
- Sidekiq::Queue.new(route_params[:name]).clear
104
+ queue = Sidekiq::Queue.new(route_params[:name])
105
+
106
+ if Sidekiq.pro? && params["pause"]
107
+ queue.pause!
108
+ elsif Sidekiq.pro? && params["unpause"]
109
+ queue.unpause!
110
+ else
111
+ queue.clear
112
+ end
84
113
 
85
114
  redirect "#{root_path}queues"
86
115
  end
87
116
 
88
117
  post "/queues/:name/delete" do
89
118
  name = route_params[:name]
90
- Sidekiq::Job.new(params['key_val'], name).delete
119
+ Sidekiq::JobRecord.new(params["key_val"], name).delete
91
120
 
92
- redirect_with_query("#{root_path}queues/#{name}")
121
+ redirect_with_query("#{root_path}queues/#{CGI.escape(name)}")
93
122
  end
94
123
 
95
- get '/morgue' do
96
- @count = (params['count'] || 25).to_i
97
- (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true)
124
+ get "/morgue" do
125
+ @count = (params["count"] || 25).to_i
126
+ (@current_page, @total_size, @dead) = page("dead", params["page"], @count, reverse: true)
98
127
  @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
99
128
 
100
129
  erb(:morgue)
101
130
  end
102
131
 
103
132
  get "/morgue/:key" do
104
- halt(404) unless key = route_params[:key]
133
+ key = route_params[:key]
134
+ halt(404) unless key
105
135
 
106
136
  @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
107
137
 
@@ -112,10 +142,10 @@ module Sidekiq
112
142
  end
113
143
  end
114
144
 
115
- post '/morgue' do
116
- redirect(request.path) unless params['key']
145
+ post "/morgue" do
146
+ redirect(request.path) unless params["key"]
117
147
 
118
- params['key'].each do |key|
148
+ params["key"].each do |key|
119
149
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
120
150
  retry_or_delete_or_kill job, params if job
121
151
  end
@@ -136,7 +166,8 @@ module Sidekiq
136
166
  end
137
167
 
138
168
  post "/morgue/:key" do
139
- halt(404) unless key = route_params[:key]
169
+ key = route_params[:key]
170
+ halt(404) unless key
140
171
 
141
172
  job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first
142
173
  retry_or_delete_or_kill job, params if job
@@ -144,9 +175,9 @@ module Sidekiq
144
175
  redirect_with_query("#{root_path}morgue")
145
176
  end
146
177
 
147
- get '/retries' do
148
- @count = (params['count'] || 25).to_i
149
- (@current_page, @total_size, @retries) = page("retry", params['page'], @count)
178
+ get "/retries" do
179
+ @count = (params["count"] || 25).to_i
180
+ (@current_page, @total_size, @retries) = page("retry", params["page"], @count)
150
181
  @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
151
182
 
152
183
  erb(:retries)
@@ -162,10 +193,10 @@ module Sidekiq
162
193
  end
163
194
  end
164
195
 
165
- post '/retries' do
166
- redirect(request.path) unless params['key']
196
+ post "/retries" do
197
+ redirect(request.path) unless params["key"]
167
198
 
168
- params['key'].each do |key|
199
+ params["key"].each do |key|
169
200
  job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first
170
201
  retry_or_delete_or_kill job, params if job
171
202
  end
@@ -185,6 +216,12 @@ module Sidekiq
185
216
  redirect "#{root_path}retries"
186
217
  end
187
218
 
219
+ post "/retries/all/kill" do
220
+ Sidekiq::RetrySet.new.kill_all
221
+
222
+ redirect "#{root_path}retries"
223
+ end
224
+
188
225
  post "/retries/:key" do
189
226
  job = Sidekiq::RetrySet.new.fetch(*parse_params(route_params[:key])).first
190
227
 
@@ -193,9 +230,9 @@ module Sidekiq
193
230
  redirect_with_query("#{root_path}retries")
194
231
  end
195
232
 
196
- get '/scheduled' do
197
- @count = (params['count'] || 25).to_i
198
- (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count)
233
+ get "/scheduled" do
234
+ @count = (params["count"] || 25).to_i
235
+ (@current_page, @total_size, @scheduled) = page("schedule", params["page"], @count)
199
236
  @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
200
237
 
201
238
  erb(:scheduled)
@@ -211,10 +248,10 @@ module Sidekiq
211
248
  end
212
249
  end
213
250
 
214
- post '/scheduled' do
215
- redirect(request.path) unless params['key']
251
+ post "/scheduled" do
252
+ redirect(request.path) unless params["key"]
216
253
 
217
- params['key'].each do |key|
254
+ params["key"].each do |key|
218
255
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
219
256
  delete_or_add_queue job, params if job
220
257
  end
@@ -223,7 +260,8 @@ module Sidekiq
223
260
  end
224
261
 
225
262
  post "/scheduled/:key" do
226
- halt(404) unless key = route_params[:key]
263
+ key = route_params[:key]
264
+ halt(404) unless key
227
265
 
228
266
  job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first
229
267
  delete_or_add_queue job, params if job
@@ -231,88 +269,76 @@ module Sidekiq
231
269
  redirect_with_query("#{root_path}scheduled")
232
270
  end
233
271
 
234
- get '/dashboard/stats' do
272
+ get "/dashboard/stats" do
235
273
  redirect "#{root_path}stats"
236
274
  end
237
275
 
238
- get '/stats' do
276
+ get "/stats" do
239
277
  sidekiq_stats = Sidekiq::Stats.new
240
- redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
241
-
278
+ redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
242
279
  json(
243
280
  sidekiq: {
244
- processed: sidekiq_stats.processed,
245
- failed: sidekiq_stats.failed,
246
- busy: sidekiq_stats.workers_size,
247
- processes: sidekiq_stats.processes_size,
248
- enqueued: sidekiq_stats.enqueued,
249
- scheduled: sidekiq_stats.scheduled_size,
250
- retries: sidekiq_stats.retry_size,
251
- dead: sidekiq_stats.dead_size,
281
+ processed: sidekiq_stats.processed,
282
+ failed: sidekiq_stats.failed,
283
+ busy: sidekiq_stats.workers_size,
284
+ processes: sidekiq_stats.processes_size,
285
+ enqueued: sidekiq_stats.enqueued,
286
+ scheduled: sidekiq_stats.scheduled_size,
287
+ retries: sidekiq_stats.retry_size,
288
+ dead: sidekiq_stats.dead_size,
252
289
  default_latency: sidekiq_stats.default_queue_latency
253
290
  },
254
- redis: redis_stats
291
+ redis: redis_stats,
292
+ server_utc_time: server_utc_time
255
293
  )
256
294
  end
257
295
 
258
- get '/stats/queues' do
296
+ get "/stats/queues" do
259
297
  json Sidekiq::Stats::Queues.new.lengths
260
298
  end
261
299
 
262
300
  def call(env)
263
301
  action = self.class.match(env)
264
- return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
302
+ return [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass"}, ["Not Found"]] unless action
265
303
 
304
+ app = @klass
266
305
  resp = catch(:halt) do
267
- app = @klass
268
306
  self.class.run_befores(app, action)
269
- begin
270
- resp = action.instance_exec env, &action.block
271
- ensure
272
- self.class.run_afters(app, action)
273
- end
274
-
275
- resp
307
+ action.instance_exec env, &action.block
308
+ ensure
309
+ self.class.run_afters(app, action)
276
310
  end
277
311
 
278
- resp = case resp
312
+ case resp
279
313
  when Array
314
+ # redirects go here
280
315
  resp
281
- when Fixnum
282
- [resp, {}, []]
283
316
  else
284
- type_header = case action.type
285
- when :json
286
- { "Content-Type" => "application/json", "Cache-Control" => "no-cache" }
287
- when String
288
- { "Content-Type" => action.type, "Cache-Control" => "no-cache" }
289
- else
290
- { "Content-Type" => "text/html", "Cache-Control" => "no-cache" }
291
- end
292
-
293
- [200, type_header, [resp]]
317
+ # rendered content goes here
318
+ headers = {
319
+ "Content-Type" => "text/html",
320
+ "Cache-Control" => "private, no-store",
321
+ "Content-Language" => action.locale,
322
+ "Content-Security-Policy" => CSP_HEADER
323
+ }
324
+ # we'll let Rack calculate Content-Length for us.
325
+ [200, headers, [resp]]
294
326
  end
295
-
296
- resp[1] = resp[1].dup
297
-
298
- resp[1][CONTENT_LENGTH] = resp[2].inject(0) { |l, p| l + p.bytesize }.to_s
299
-
300
- resp
301
327
  end
302
328
 
303
- def self.helpers(mod=nil, &block)
304
- if block_given?
329
+ def self.helpers(mod = nil, &block)
330
+ if block
305
331
  WebAction.class_eval(&block)
306
332
  else
307
333
  WebAction.send(:include, mod)
308
334
  end
309
335
  end
310
336
 
311
- def self.before(path=nil, &block)
337
+ def self.before(path = nil, &block)
312
338
  befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
313
339
  end
314
340
 
315
- def self.after(path=nil, &block)
341
+ def self.after(path = nil, &block)
316
342
  afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block]
317
343
  end
318
344
 
@@ -325,8 +351,8 @@ module Sidekiq
325
351
  end
326
352
 
327
353
  def self.run_hooks(hooks, app, action)
328
- hooks.select { |p,_| !p || p =~ action.env[WebRouter::PATH_INFO] }.
329
- each {|_,b| action.instance_exec(action.env, app, &b) }
354
+ hooks.select { |p, _| !p || p =~ action.env[WebRouter::PATH_INFO] }
355
+ .each { |_, b| action.instance_exec(action.env, app, &b) }
330
356
  end
331
357
 
332
358
  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.strict_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.strict_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