Skip to content

Commit

Permalink
Add support for per form csrf tokens
Browse files Browse the repository at this point in the history
Model the implementation after Rails to provide cross compatibility.
  • Loading branch information
jkowens committed Oct 9, 2020
1 parent dba4119 commit b35db88
Show file tree
Hide file tree
Showing 2 changed files with 69 additions and 18 deletions.
73 changes: 56 additions & 17 deletions rack-protection/lib/rack/protection/authenticity_token.rb
@@ -1,5 +1,6 @@
require 'rack/protection'
require 'securerandom'
require 'openssl'
require 'base64'

module Rack
Expand Down Expand Up @@ -95,40 +96,53 @@ class AuthenticityToken < Base
: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
Expand All @@ -139,13 +153,13 @@ def valid_token?(session, token)
# 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
Expand All @@ -155,7 +169,6 @@ def valid_token?(session, token)
# 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
Expand Down Expand Up @@ -184,16 +197,42 @@ def compare_with_real_token(token, session)
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)
Expand Down
Expand Up @@ -40,6 +40,18 @@
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
Expand Down Expand Up @@ -86,7 +98,7 @@

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

0 comments on commit b35db88

Please sign in to comment.