feat(security): add Prism, CookieJar and Csrf
This commit is contained in:
1 parent
e75dc076e5
commit
948376736e
2 files changed
+147
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
module Prism
|
||||||
|
# Ties MessageVerifier/MessageEncryptor to a request/response pair so
|
||||||
|
# controllers can read and write tamper-evident or fully opaque cookies
|
||||||
|
# without touching OpenSSL directly.
|
||||||
|
class CookieJar
|
||||||
|
# `previous_secrets:` gives cookies.signed/cookies.encrypted the same
|
||||||
|
# rotation contract as Application#enable_attribute_encryption and
|
||||||
|
# Prism::Session: a cookie written under a retired secret still reads
|
||||||
|
# back, a fresh #[]= write always signs/encrypts under `secret`.
|
||||||
|
def initialize(request, response, secret:, previous_secrets: [])
|
||||||
|
@request = request
|
||||||
|
@response = response
|
||||||
|
@verifier = MessageVerifier.new(secret)
|
||||||
|
@encryptor = MessageEncryptor.new(MessageEncryptor.derive_key(secret))
|
||||||
|
@previous_verifiers = previous_secrets.map { |previous| MessageVerifier.new(previous) }
|
||||||
|
@previous_encryptors = previous_secrets.map { |previous| MessageEncryptor.new(MessageEncryptor.derive_key(previous)) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def [](name)
|
||||||
|
@request.cookies[name.to_s]
|
||||||
|
end
|
||||||
|
|
||||||
|
def []=(name, value_or_options)
|
||||||
|
value, options = self.class.split_value(value_or_options)
|
||||||
|
set(name, value, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set(name, value, options = {})
|
||||||
|
@response.set_cookie(name, value.to_s, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed
|
||||||
|
@signed ||= Accessor.new(self, @verifier, :generate, :verified, fallback_codecs: @previous_verifiers)
|
||||||
|
end
|
||||||
|
|
||||||
|
def encrypted
|
||||||
|
@encrypted ||= Accessor.new(self, @encryptor, :encrypt_and_sign, :decrypt_and_verify_safe, fallback_codecs: @previous_encryptors)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.split_value(value_or_options)
|
||||||
|
if value_or_options.is_a?(Hash) && value_or_options.key?(:value)
|
||||||
|
options = value_or_options.dup
|
||||||
|
[options.delete(:value), options]
|
||||||
|
else
|
||||||
|
[value_or_options, {}]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Accessor
|
||||||
|
def initialize(jar, codec, encode_method, decode_method, fallback_codecs: [])
|
||||||
|
@jar = jar
|
||||||
|
@codec = codec
|
||||||
|
@encode_method = encode_method
|
||||||
|
@decode_method = decode_method
|
||||||
|
@fallback_codecs = fallback_codecs
|
||||||
|
end
|
||||||
|
|
||||||
|
def [](name)
|
||||||
|
raw = @jar[name]
|
||||||
|
return nil if raw.nil?
|
||||||
|
decoded = @codec.public_send(@decode_method, raw)
|
||||||
|
return decoded unless decoded.nil?
|
||||||
|
@fallback_codecs.each do |codec|
|
||||||
|
decoded = codec.public_send(@decode_method, raw)
|
||||||
|
return decoded unless decoded.nil?
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def []=(name, value_or_options)
|
||||||
|
value, options = CookieJar.split_value(value_or_options)
|
||||||
|
@jar.set(name, @codec.public_send(@encode_method, value), options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class MessageEncryptor
|
||||||
|
def decrypt_and_verify_safe(message)
|
||||||
|
decrypt_and_verify(message)
|
||||||
|
rescue InvalidMessage
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
require 'securerandom'
|
||||||
|
|
||||||
|
module Prism
|
||||||
|
# Per-request masked CSRF tokens (the same scheme Rails uses): the raw
|
||||||
|
# token lives in the session, but every rendered token is XORed with a
|
||||||
|
# fresh random mask so two tokens issued to the same page never look
|
||||||
|
# alike. That defeats BREACH-style compression oracles against a token
|
||||||
|
# that would otherwise be a constant string embedded next to attacker-
|
||||||
|
# influenced response bytes.
|
||||||
|
class Csrf
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
SESSION_KEY = '_csrf_token'
|
||||||
|
|
||||||
|
def initialize(session)
|
||||||
|
@session = session
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_token
|
||||||
|
@session[SESSION_KEY] ||= encode(SecureRandom.random_bytes(TOKEN_BYTES))
|
||||||
|
end
|
||||||
|
|
||||||
|
def masked_token
|
||||||
|
mask = SecureRandom.random_bytes(TOKEN_BYTES)
|
||||||
|
masked = xor(decode(raw_token), mask)
|
||||||
|
encode(mask + masked)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_token?(token)
|
||||||
|
return false if token.nil? || token.empty?
|
||||||
|
decoded = decode(token)
|
||||||
|
return false unless decoded && decoded.bytesize == TOKEN_BYTES * 2
|
||||||
|
mask = decoded.byteslice(0, TOKEN_BYTES)
|
||||||
|
masked = decoded.byteslice(TOKEN_BYTES, TOKEN_BYTES)
|
||||||
|
secure_compare(xor(masked, mask), decode(raw_token))
|
||||||
|
rescue ArgumentError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def xor(left, right)
|
||||||
|
left.bytes.zip(right.bytes).map { |a, b| a ^ b }.pack('C*')
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(bytes)
|
||||||
|
[bytes].pack('m0').tr('+/', '-_').delete('=')
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(text)
|
||||||
|
restored = text.tr('-_', '+/')
|
||||||
|
padding = (4 - restored.bytesize % 4) % 4
|
||||||
|
(restored + ('=' * padding)).unpack1('m0')
|
||||||
|
end
|
||||||
|
|
||||||
|
def secure_compare(left, right)
|
||||||
|
return false unless left.bytesize == right.bytesize
|
||||||
|
left_bytes = left.unpack("C#{left.bytesize}")
|
||||||
|
difference = 0
|
||||||
|
right.each_byte.with_index { |byte, index| difference |= byte ^ left_bytes[index] }
|
||||||
|
difference.zero?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user