feat(security): add Prism, CookieJar and Csrf

This commit is contained in:
dualfroz committed 2026-09-08 21:14:57 +02:00
1 parent e75dc076e5
commit 948376736e
2 files changed
+147

No files matched your search

+84
View File
@@ -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
+63
View File
@@ -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