class Sidekiq::Web::CsrfProtection

Constants

TOKEN_LENGTH

Public Class Methods

new(app, options = nil) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 35
def initialize(app, options = nil)
  @app = app
end

Public Instance Methods

call(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 39
def call(env)
  accept?(env) ? admit(env) : deny(env)
end

Private Instance Methods

accept?(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 95
def accept?(env)
  return true if safe?(env)

  giventoken = ::Rack::Request.new(env).params["authenticity_token"]
  valid_token?(env, giventoken)
end
admit(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 45
def admit(env)
  # On each successful request, we create a fresh masked token
  # which will be used in any forms rendered for this request.
  s = session(env)
  s[:csrf] ||= SecureRandom.base64(TOKEN_LENGTH)
  env[:csrf_token] = mask_token(s[:csrf])
  @app.call(env)
end
compare_with_real_token(token, local) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 166
def compare_with_real_token(token, local)
  ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
end
decode_token(token) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 174
def decode_token(token)
  token.tr("-_", "+/").unpack1("m0")
end
deny(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 62
def deny(env)
  logger(env).warn "attack prevented by #{self.class}"
  [403, {Rack::CONTENT_TYPE => "text/plain"}, ["Forbidden"]]
end
encode_token(token) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 170
def encode_token(token)
  [token].pack("m0").tr("+/", "-_")
end
logger(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 58
def logger(env)
  @logger ||= env["rack.logger"] || ::Logger.new(env["rack.errors"])
end
mask_token(token) click to toggle source

Creates a masked version of the authenticity token that varies on each request. The masking is used to mitigate SSL attacks like BREACH.

# File lib/sidekiq/web/csrf_protection.rb, line 140
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
masked_token?(token) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 162
def masked_token?(token)
  token.length == TOKEN_LENGTH * 2
end
safe?(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 54
def safe?(env)
  %w[GET HEAD OPTIONS TRACE].include? env["REQUEST_METHOD"]
end
session(env) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 67
      def session(env)
        env["rack.session"] || fail(<<~EOM)
          Sidekiq::Web needs a valid Rack session for CSRF protection. If this is a Rails app,
          make sure you mount Sidekiq::Web *inside* your application routes:


          Rails.application.routes.draw do
            mount Sidekiq::Web => "/sidekiq"
            ....
          end


          If this is a Rails app in API mode, you need to enable sessions.

            https://guides.rubyonrails.org/api_app.html#using-session-middlewares

          If this is a bare Rack app, use a session middleware before Sidekiq::Web:

            # first, use IRB to create a shared secret key for sessions and commit it
            require 'securerandom'; File.open(".session.key", "w") {|f| f.write(SecureRandom.hex(32)) }

            # now use the secret with a session cookie middleware
            use Rack::Session::Cookie, secret: File.read(".session.key"), same_site: true, max_age: 86400
            run Sidekiq::Web

        EOM
      end
unmask_token(masked_token) click to toggle source

Essentially the inverse of mask_token.

# File lib/sidekiq/web/csrf_protection.rb, line 149
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..]
  xor_byte_strings(one_time_pad, encrypted_token)
end
unmasked_token?(token) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 158
def unmasked_token?(token)
  token.length == TOKEN_LENGTH
end
valid_token?(env, giventoken) click to toggle source

Checks that the token given to us as a parameter matches the token stored in the session.

# File lib/sidekiq/web/csrf_protection.rb, line 106
def valid_token?(env, giventoken)
  return false if giventoken.nil? || giventoken.empty?

  begin
    token = decode_token(giventoken)
  rescue ArgumentError # client input is invalid
    return false
  end

  sess = session(env)
  localtoken = sess[:csrf]

  # Checks that Rack::Session::Cookie actually contains the csrf token
  return false if localtoken.nil?

  # Rotate the session token after every use
  sess[:csrf] = SecureRandom.base64(TOKEN_LENGTH)

  # 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, localtoken
  elsif masked_token?(token)
    unmasked = unmask_token(token)
    compare_with_real_token unmasked, localtoken
  else
    false # Token is malformed
  end
end
xor_byte_strings(s1, s2) click to toggle source
# File lib/sidekiq/web/csrf_protection.rb, line 178
def xor_byte_strings(s1, s2)
  s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*")
end