Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for per form csrf tokens #1653

Merged
merged 2 commits into from Feb 14, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)
jkowens marked this conversation as resolved.
Show resolved Hide resolved
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)
jkowens marked this conversation as resolved.
Show resolved Hide resolved
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