diff --git a/Changes.md b/Changes.md index 261a6b579..92ccfca0a 100644 --- a/Changes.md +++ b/Changes.md @@ -9,6 +9,7 @@ HEAD - Ensure `Rack::ContentLength` is loaded as middleware for correct Web UI responses [#4541] - Avoid exception dumping SSL store in Redis connection logging [#4532] - Better error messages in Sidekiq::Client [#4549] +- Vendor rack-protection/authenticity_token middleware [#4588] 6.0.7 --------- diff --git a/Gemfile.lock b/Gemfile.lock index 743e63e58..96a13630a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,6 @@ PATH sidekiq (6.0.7) connection_pool (>= 2.2.2) rack (~> 2.0) - rack-protection (>= 2.0.0) redis (>= 4.1.0) GEM @@ -117,8 +116,6 @@ GEM byebug (~> 11.0) pry (~> 0.10) rack (2.2.2) - rack-protection (2.0.8.1) - rack rack-test (1.1.0) rack (>= 1.0, < 3) rails (6.0.3.1) diff --git a/lib/sidekiq/web.rb b/lib/sidekiq/web.rb index 3422bc510..f989ba79f 100644 --- a/lib/sidekiq/web.rb +++ b/lib/sidekiq/web.rb @@ -10,8 +10,8 @@ require "sidekiq/web/router" require "sidekiq/web/action" require "sidekiq/web/application" +require "sidekiq/web/authenticity_token" -require "rack/protection" require "rack/content_length" require "rack/builder" @@ -155,8 +155,8 @@ def using?(middleware) def build_sessions middlewares = self.middlewares - unless using?(::Rack::Protection) || ENV["RACK_ENV"] == "test" - middlewares.unshift [[::Rack::Protection, {use: :authenticity_token}], nil] + unless using?(AuthenticityToken) || ENV["RACK_ENV"] == "test" + middlewares.unshift [[AuthenticityToken], nil] end s = sessions diff --git a/lib/sidekiq/web/authenticity_token.rb b/lib/sidekiq/web/authenticity_token.rb new file mode 100644 index 000000000..80253d8b7 --- /dev/null +++ b/lib/sidekiq/web/authenticity_token.rb @@ -0,0 +1,254 @@ +# this file came from the sinatra/rack-protection project +# +# The MIT License (MIT) +# +# Copyright (c) 2011-2017 Konstantin Haase +# Copyright (c) 2015-2017 Zachary Scott +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# 'Software'), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +require "securerandom" +require "base64" +require "rack/request" + +module Sidekiq + class Web + class AuthenticityToken + DEFAULT_OPTIONS = { + reaction: :default_reaction, logging: true, + message: "Forbidden", encryptor: Digest::SHA1, + session_key: "rack.session", status: 403, + allow_empty_referrer: true, + report_key: "protection.failed", + html_types: %w[text/html application/xhtml text/xml application/xml] + } + + attr_reader :app, :options + + def self.default_options(options) + define_method(:default_options) { DEFAULT_OPTIONS.merge(options) } + end + + def self.default_reaction(reaction) + alias_method(:default_reaction, reaction) + end + + def default_options + DEFAULT_OPTIONS + end + + def initialize(app, options = {}) + @app, @options = app, default_options.merge(options) + end + + def safe?(env) + %w[GET HEAD OPTIONS TRACE].include? env["REQUEST_METHOD"] + end + + def call(env) + unless accepts? env + instrument env + result = react env + end + result || app.call(env) + end + + def react(env) + result = send(options[:reaction], env) + result if (Array === result) && (result.size == 3) + end + + def warn(env, message) + return unless options[:logging] + l = options[:logger] || env["rack.logger"] || ::Logger.new(env["rack.errors"]) + l.warn(message) + end + + def instrument(env) + return unless (i = options[:instrumenter]) + env["rack.protection.attack"] = self.class.name.split("::").last.downcase + i.instrument("rack.protection", env) + end + + def deny(env) + warn env, "attack prevented by #{self.class}" + [options[:status], {"Content-Type" => "text/plain"}, [options[:message]]] + end + + def report(env) + warn env, "attack reported by #{self.class}" + env[options[:report_key]] = true + end + + def session?(env) + env.include? options[:session_key] + end + + def session(env) + return env[options[:session_key]] if session? env + fail "you need to set up a session middleware *before* #{self.class}" + end + + def drop_session(env) + session(env).clear if session? env + end + + def referrer(env) + ref = env["HTTP_REFERER"].to_s + return if !options[:allow_empty_referrer] && ref.empty? + URI.parse(ref).host || Rack::Request.new(env).host + rescue URI::InvalidURIError + end + + def origin(env) + env["HTTP_ORIGIN"] || env["HTTP_X_ORIGIN"] + end + + def random_string(secure = defined? SecureRandom) + secure ? SecureRandom.hex(16) : "%032x" % rand(2**128 - 1) + rescue NotImplementedError + random_string false + end + + def encrypt(value) + options[:encryptor].hexdigest value.to_s + end + + def secure_compare(a, b) + Rack::Utils.secure_compare(a.to_s, b.to_s) + end + + def html?(headers) + return false unless (header = headers.detect { |k, v| k.downcase == "content-type" }) + options[:html_types].include? header.last[/^\w+\/\w+/] + end + + TOKEN_LENGTH = 32 + + default_options authenticity_param: "authenticity_token", + allow_if: nil + + def self.token(session) + new(nil).mask_authenticity_token(session) + end + + def self.random_token + SecureRandom.base64(TOKEN_LENGTH) + end + + def accepts?(env) + session = session env + set_token(session) + + safe?(env) || + valid_token?(session, env["HTTP_X_CSRF_TOKEN"]) || + valid_token?(session, Rack::Request.new(env).params[options[:authenticity_param]]) || + options[:allow_if]&.call(env) + end + + def mask_authenticity_token(session) + token = set_token(session) + mask_token(token) + end + + private + + def set_token(session) + session[:csrf] ||= self.class.random_token + end + + # Checks the client's masked token to see if it matches the + # session token. + def valid_token?(session, token) + return false if token.nil? || token.empty? + + begin + token = decode_token(token) + rescue ArgumentError # encoded_masked_token is invalid Base64 + return false + end + + # See if it's actually a masked token or not. We should be able + # to handle any unmasked tokens that we've issued without error. + + if unmasked_token?(token) + compare_with_real_token token, session + + elsif masked_token?(token) + token = unmask_token(token) + + compare_with_real_token token, session + + else + false # Token is malformed + end + end + + # Creates a masked version of the authenticity token that varies + # on each request. The masking is used to mitigate SSL attacks + # like BREACH. + def mask_token(token) + token = decode_token(token) + one_time_pad = SecureRandom.random_bytes(token.length) + encrypted_token = xor_byte_strings(one_time_pad, token) + masked_token = one_time_pad + encrypted_token + encode_token(masked_token) + end + + # Essentially the inverse of +mask_token+. + def unmask_token(masked_token) + # Split the token into the one-time pad and the encrypted + # value and decrypt it + token_length = masked_token.length / 2 + one_time_pad = masked_token[0...token_length] + encrypted_token = masked_token[token_length..-1] + xor_byte_strings(one_time_pad, encrypted_token) + end + + def unmasked_token?(token) + token.length == TOKEN_LENGTH + end + + def masked_token?(token) + token.length == TOKEN_LENGTH * 2 + end + + def compare_with_real_token(token, session) + secure_compare(token, real_token(session)) + end + + def real_token(session) + decode_token(session[:csrf]) + end + + def encode_token(token) + Base64.strict_encode64(token) + end + + def decode_token(token) + Base64.strict_decode64(token) + end + + def xor_byte_strings(s1, s2) + s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*") + end + end + end +end diff --git a/sidekiq.gemspec b/sidekiq.gemspec index 510f8cb5b..808b57052 100644 --- a/sidekiq.gemspec +++ b/sidekiq.gemspec @@ -17,5 +17,4 @@ Gem::Specification.new do |gem| gem.add_dependency "redis", ">= 4.1.0" gem.add_dependency "connection_pool", ">= 2.2.2" gem.add_dependency "rack", "~> 2.0" - gem.add_dependency "rack-protection", ">= 2.0.0" end