sidekiq 5.2.10 → 6.5.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Changes.md +422 -1
- data/LICENSE +3 -3
- data/README.md +24 -35
- data/bin/sidekiq +27 -3
- data/bin/sidekiqload +79 -67
- data/bin/sidekiqmon +8 -0
- data/lib/generators/sidekiq/job_generator.rb +57 -0
- data/lib/generators/sidekiq/templates/{worker.rb.erb → job.rb.erb} +2 -2
- data/lib/generators/sidekiq/templates/{worker_spec.rb.erb → job_spec.rb.erb} +1 -1
- data/lib/generators/sidekiq/templates/{worker_test.rb.erb → job_test.rb.erb} +1 -1
- data/lib/sidekiq/api.rb +527 -310
- data/lib/sidekiq/cli.rb +204 -208
- data/lib/sidekiq/client.rb +78 -82
- data/lib/sidekiq/component.rb +65 -0
- data/lib/sidekiq/delay.rb +8 -7
- data/lib/sidekiq/extensions/action_mailer.rb +13 -22
- data/lib/sidekiq/extensions/active_record.rb +13 -10
- data/lib/sidekiq/extensions/class_methods.rb +14 -11
- data/lib/sidekiq/extensions/generic_proxy.rb +7 -5
- data/lib/sidekiq/fetch.rb +50 -40
- data/lib/sidekiq/job.rb +13 -0
- data/lib/sidekiq/job_logger.rb +33 -7
- data/lib/sidekiq/job_retry.rb +126 -106
- data/lib/sidekiq/job_util.rb +71 -0
- data/lib/sidekiq/launcher.rb +177 -83
- data/lib/sidekiq/logger.rb +156 -0
- data/lib/sidekiq/manager.rb +40 -41
- data/lib/sidekiq/metrics/deploy.rb +47 -0
- data/lib/sidekiq/metrics/query.rb +153 -0
- data/lib/sidekiq/metrics/shared.rb +94 -0
- data/lib/sidekiq/metrics/tracking.rb +134 -0
- data/lib/sidekiq/middleware/chain.rb +102 -46
- data/lib/sidekiq/middleware/current_attributes.rb +63 -0
- data/lib/sidekiq/middleware/i18n.rb +7 -7
- data/lib/sidekiq/middleware/modules.rb +21 -0
- data/lib/sidekiq/monitor.rb +133 -0
- data/lib/sidekiq/paginator.rb +28 -16
- data/lib/sidekiq/processor.rb +104 -97
- data/lib/sidekiq/rails.rb +46 -37
- data/lib/sidekiq/redis_client_adapter.rb +154 -0
- data/lib/sidekiq/redis_connection.rb +108 -77
- data/lib/sidekiq/ring_buffer.rb +29 -0
- data/lib/sidekiq/scheduled.rb +105 -42
- data/lib/sidekiq/sd_notify.rb +149 -0
- data/lib/sidekiq/systemd.rb +24 -0
- data/lib/sidekiq/testing/inline.rb +6 -5
- data/lib/sidekiq/testing.rb +68 -58
- data/lib/sidekiq/transaction_aware_client.rb +45 -0
- data/lib/sidekiq/version.rb +2 -1
- data/lib/sidekiq/web/action.rb +15 -11
- data/lib/sidekiq/web/application.rb +103 -77
- data/lib/sidekiq/web/csrf_protection.rb +180 -0
- data/lib/sidekiq/web/helpers.rb +125 -95
- data/lib/sidekiq/web/router.rb +23 -19
- data/lib/sidekiq/web.rb +65 -105
- data/lib/sidekiq/worker.rb +259 -109
- data/lib/sidekiq.rb +170 -62
- data/sidekiq.gemspec +23 -16
- data/web/assets/images/apple-touch-icon.png +0 -0
- data/web/assets/javascripts/application.js +113 -61
- data/web/assets/javascripts/chart.min.js +13 -0
- data/web/assets/javascripts/chartjs-plugin-annotation.min.js +7 -0
- data/web/assets/javascripts/dashboard.js +53 -89
- data/web/assets/javascripts/graph.js +16 -0
- data/web/assets/javascripts/metrics.js +262 -0
- data/web/assets/stylesheets/application-dark.css +143 -0
- data/web/assets/stylesheets/application-rtl.css +0 -4
- data/web/assets/stylesheets/application.css +88 -233
- data/web/locales/ar.yml +8 -2
- data/web/locales/de.yml +14 -2
- data/web/locales/el.yml +43 -19
- data/web/locales/en.yml +13 -1
- data/web/locales/es.yml +18 -2
- data/web/locales/fr.yml +10 -3
- data/web/locales/ja.yml +14 -1
- data/web/locales/lt.yml +83 -0
- data/web/locales/pl.yml +4 -4
- data/web/locales/pt-br.yml +27 -9
- data/web/locales/ru.yml +4 -0
- data/web/locales/vi.yml +83 -0
- data/web/locales/zh-cn.yml +36 -11
- data/web/locales/zh-tw.yml +32 -7
- data/web/views/_footer.erb +1 -1
- data/web/views/_job_info.erb +3 -2
- data/web/views/_nav.erb +1 -1
- data/web/views/_poll_link.erb +2 -5
- data/web/views/_summary.erb +7 -7
- data/web/views/busy.erb +61 -22
- data/web/views/dashboard.erb +23 -14
- data/web/views/dead.erb +3 -3
- data/web/views/layout.erb +3 -1
- data/web/views/metrics.erb +69 -0
- data/web/views/metrics_for_job.erb +87 -0
- data/web/views/morgue.erb +9 -6
- data/web/views/queue.erb +23 -10
- data/web/views/queues.erb +10 -2
- data/web/views/retries.erb +11 -8
- data/web/views/retry.erb +3 -3
- data/web/views/scheduled.erb +5 -2
- metadata +58 -63
- data/.circleci/config.yml +0 -61
- data/.github/contributing.md +0 -32
- data/.github/issue_template.md +0 -11
- data/.gitignore +0 -15
- data/.travis.yml +0 -11
- data/3.0-Upgrade.md +0 -70
- data/4.0-Upgrade.md +0 -53
- data/5.0-Upgrade.md +0 -56
- data/COMM-LICENSE +0 -97
- data/Ent-Changes.md +0 -238
- data/Gemfile +0 -19
- data/Pro-2.0-Upgrade.md +0 -138
- data/Pro-3.0-Upgrade.md +0 -44
- data/Pro-4.0-Upgrade.md +0 -35
- data/Pro-Changes.md +0 -759
- data/Rakefile +0 -9
- data/bin/sidekiqctl +0 -20
- data/code_of_conduct.md +0 -50
- data/lib/generators/sidekiq/worker_generator.rb +0 -49
- data/lib/sidekiq/core_ext.rb +0 -1
- data/lib/sidekiq/ctl.rb +0 -221
- data/lib/sidekiq/exception_handler.rb +0 -29
- data/lib/sidekiq/logging.rb +0 -122
- data/lib/sidekiq/middleware/server/active_record.rb +0 -23
- data/lib/sidekiq/util.rb +0 -66
@@ -4,9 +4,7 @@ module Sidekiq
|
|
4
4
|
class WebApplication
|
5
5
|
extend WebRouter
|
6
6
|
|
7
|
-
|
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(
|
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[
|
64
|
-
p = Sidekiq::Process.new(
|
65
|
-
p.quiet! if params[
|
66
|
-
p.stop! if params[
|
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[
|
70
|
-
pro.stop! if params[
|
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)
|
109
|
+
halt(404) if !@name || @name !~ QUEUE_NAME
|
87
110
|
|
88
|
-
@count = (params[
|
111
|
+
@count = (params["count"] || 25).to_i
|
89
112
|
@queue = Sidekiq::Queue.new(@name)
|
90
|
-
(@current_page, @total_size, @
|
91
|
-
@
|
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])
|
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::
|
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
|
110
|
-
@count = (params[
|
111
|
-
(@current_page, @total_size, @dead) = page("dead", params[
|
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
|
-
|
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
|
130
|
-
redirect(request.path) unless params[
|
161
|
+
post "/morgue" do
|
162
|
+
redirect(request.path) unless params["key"]
|
131
163
|
|
132
|
-
params[
|
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
|
-
|
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
|
162
|
-
@count = (params[
|
163
|
-
(@current_page, @total_size, @retries) = page("retry", params[
|
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
|
180
|
-
redirect(request.path) unless params[
|
212
|
+
post "/retries" do
|
213
|
+
redirect(request.path) unless params["key"]
|
181
214
|
|
182
|
-
params[
|
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
|
217
|
-
@count = (params[
|
218
|
-
(@current_page, @total_size, @scheduled) = page("schedule", params[
|
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
|
235
|
-
redirect(request.path) unless params[
|
267
|
+
post "/scheduled" do
|
268
|
+
redirect(request.path) unless params["key"]
|
236
269
|
|
237
|
-
params[
|
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
|
-
|
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
|
288
|
+
get "/dashboard/stats" do
|
255
289
|
redirect "#{root_path}stats"
|
256
290
|
end
|
257
291
|
|
258
|
-
get
|
292
|
+
get "/stats" do
|
259
293
|
sidekiq_stats = Sidekiq::Stats.new
|
260
|
-
redis_stats
|
294
|
+
redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k }
|
261
295
|
json(
|
262
296
|
sidekiq: {
|
263
|
-
processed:
|
264
|
-
failed:
|
265
|
-
busy:
|
266
|
-
processes:
|
267
|
-
enqueued:
|
268
|
-
scheduled:
|
269
|
-
retries:
|
270
|
-
dead:
|
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
|
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, {"
|
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
|
-
|
290
|
-
|
291
|
-
|
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
|
-
|
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
|
-
"
|
304
|
-
"
|
305
|
-
"
|
306
|
-
"
|
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
|
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
|
-
|
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
|