Skip to content

Commit

Permalink
Merge pull request #1653 from jkowens/per-form-tokens
Browse files Browse the repository at this point in the history
Add support for per form csrf tokens
  • Loading branch information
namusyaka committed Feb 14, 2021
2 parents e79e9e6 + 88235a9 commit ed2add3
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 17 deletions.
70 changes: 54 additions & 16 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,29 +96,39 @@ 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: path, method: 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: nil, method: :post)
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)
Expand All @@ -126,9 +137,11 @@ def set_token(session)

# 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 +152,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 +168,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 +196,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 ed2add3

Please sign in to comment.