Add support for per form csrf tokens

Model the implementation after Rails to provide cross compatibility.
This commit is contained in:
Jordan Owens 2020-10-09 19:49:04 -04:00
parent e511500696
commit f8f04e5fce
2 changed files with 69 additions and 18 deletions

View File

@ -1,5 +1,6 @@
require 'rack/protection'
require 'securerandom'
require 'openssl'
require 'base64'
module Rack
@ -95,40 +96,53 @@ module Rack
:key => :csrf,
:allow_if => nil
def self.token(session)
self.new(nil).mask_authenticity_token(session)
def self.token(session, path: nil, method: :post)
self.new(nil).mask_authenticity_token(session, path, method)
end
def self.random_token
SecureRandom.base64(TOKEN_LENGTH)
SecureRandom.urlsafe_base64(TOKEN_LENGTH, padding: false)
end
def accepts?(env)
session = session env
session = session(env)
set_token(session)
safe?(env) ||
valid_token?(session, env['HTTP_X_CSRF_TOKEN']) ||
valid_token?(session, Request.new(env).params[options[:authenticity_param]]) ||
valid_token?(env, env['HTTP_X_CSRF_TOKEN']) ||
valid_token?(env, Request.new(env).params[options[:authenticity_param]]) ||
( options[:allow_if] && options[:allow_if].call(env) )
end
def mask_authenticity_token(session)
token = set_token(session)
def mask_authenticity_token(session, path, method)
set_token(session)
token = if path && method
per_form_token(session, path, method)
else
global_token(session)
end
mask_token(token)
end
GLOBAL_TOKEN_IDENTIFIER = '!real_csrf_token'
private_constant :GLOBAL_TOKEN_IDENTIFIER
private
def set_token(session)
session[options[:key]] ||= self.class.random_token
token = session[options[:key]] ||= self.class.random_token
decode_token(token)
end
# Checks the client's masked token to see if it matches the
# session token.
def valid_token?(session, token)
def valid_token?(env, token)
return false if token.nil? || token.empty?
session = session(env)
begin
token = decode_token(token)
rescue ArgumentError # encoded_masked_token is invalid Base64
@ -139,13 +153,13 @@ module Rack
# to handle any unmasked tokens that we've issued without error.
if unmasked_token?(token)
compare_with_real_token token, session
compare_with_real_token(token, session)
elsif masked_token?(token)
token = unmask_token(token)
compare_with_real_token token, session
compare_with_global_token(token, session) ||
compare_with_real_token(token, session) ||
compare_with_per_form_token(token, session, Request.new(env))
else
false # Token is malformed
end
@ -155,7 +169,6 @@ module Rack
# 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
@ -184,16 +197,42 @@ module Rack
secure_compare(token, real_token(session))
end
def compare_with_global_token(token, session)
secure_compare(token, global_token(session))
end
def compare_with_per_form_token(token, session, request)
secure_compare(token,
per_form_token(session, request.path.chomp('/'), request.request_method)
)
end
def real_token(session)
decode_token(session[options[:key]])
end
def global_token(session)
token_hmac(session, GLOBAL_TOKEN_IDENTIFIER)
end
def per_form_token(session, path, method)
token_hmac(session, "#{path}##{method.downcase}")
end
def encode_token(token)
Base64.strict_encode64(token)
Base64.urlsafe_encode64(token)
end
def decode_token(token)
Base64.strict_decode64(token)
Base64.urlsafe_decode64(token)
end
def token_hmac(session, identifier)
OpenSSL::HMAC.digest(
OpenSSL::Digest::SHA256.new,
real_token(session),
identifier
)
end
def xor_byte_strings(s1, s2)

View File

@ -40,6 +40,18 @@ describe Rack::Protection::AuthenticityToken do
expect(last_response).not_to be_ok
end
it "accepts post form requests with a valid per form token" do
token = Rack::Protection::AuthenticityToken.token(session, path: '/foo')
post('/foo', {"authenticity_token" => token}, 'rack.session' => session)
expect(last_response).to be_ok
end
it "denies post form requests with an invalid per form token" do
token = Rack::Protection::AuthenticityToken.token(session, path: '/foo')
post('/bar', {"authenticity_token" => token}, 'rack.session' => session)
expect(last_response).not_to be_ok
end
it "prevents ajax requests without a valid token" do
expect(post('/', {}, "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest")).not_to be_ok
end
@ -86,7 +98,7 @@ describe Rack::Protection::AuthenticityToken do
describe ".random_token" do
it "generates a base64 encoded 32 character string" do
expect(Base64.strict_decode64(token).length).to eq(32)
expect(Base64.urlsafe_decode64(token).length).to eq(32)
end
end
end