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

encrypted cookie jar #5034

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions actionpack/CHANGELOG.md
@@ -1,5 +1,13 @@
## Rails 4.0.0 (unreleased) ## ## Rails 4.0.0 (unreleased) ##


* Add an encrypted cookie jar. It can be used like the signed cookies.
Example:

cookies.encrypted[:buried_treasure] = "X marks the spot"
cookies.permanent.encrypted[:encrypted_permanent] = "X marks the spot... forever"

*Les Fletcher*

* Add `date_field` and `date_field_tag` helpers which render an `input[type="date"]` tag *Olek Janiszewski* * Add `date_field` and `date_field_tag` helpers which render an `input[type="date"]` tag *Olek Janiszewski*


* Adds `image_url`, `javascript_url`, `stylesheet_url`, `audio_url`, `video_url`, and `font_url` * Adds `image_url`, `javascript_url`, `stylesheet_url`, `audio_url`, `video_url`, and `font_url`
Expand Down
104 changes: 81 additions & 23 deletions actionpack/lib/action_dispatch/middleware/cookies.rb
Expand Up @@ -237,6 +237,31 @@ def signed
@signed ||= SignedCookieJar.new(self, @secret) @signed ||= SignedCookieJar.new(self, @secret)
end end


# Returns a jar that'll automatically encrypt cookies values before writing them and decrypt the values when reading. If the
# cookie is tampered with by the user (or a 3rd party), an ActiveSupport::MessageVerifier::InvalidSignature exception will
# be raised. If the cookie is verified, but unable to be decrypted, an ActiveSupport::MessageEncryptor::InvalidMessage
# exception will be raised.
#
# This jar requires that you set a suitable secret for the verification on your app's config.secret_token.
#
# Example:
#
# cookies.encrypted[:encrypted_cookie] = "you don't know what this says"
# # => Set-Cookie: LSuus8pkXd...ckqiG6qGlwuhSQn--4Eb16w1z7ouNXQZAxV5Bjw==; path=/; expires=Sun, 27-Mar-2011 03:24:16 GMT
#
# This jar allows chaining with other jars as well, so you can set permanent, encrypted cookies. Examples:
#
# cookies.permanent.encypted[:encrypted_permanent] = "you don't know what this says, but it will be here for 20 years"
# # => Set-Cookie: Sok2G6hGs...XFeUpDWQLT8=--UZe+JlZPlMuxHYSq09oV0w==; path=/; expires=Thu, 27 Mar 2031 13:48:43 GMT
#
# To read encypted cookies:
#
# cookies.encrypted[:encrypted_cookie] # => "you don't know what this says"
# cookies.encrypted[:encrypted_permanent] # => "you don't know what this says, but it will be here for 20 years"
def encrypted
@encrypted ||= EncryptedCookieJar.new(self, @secret)
end

def write(headers) def write(headers)
@set_cookies.each { |k, v| ::Rack::Utils.set_cookie_header!(headers, k, v) if write_cookie?(v) } @set_cookies.each { |k, v| ::Rack::Utils.set_cookie_header!(headers, k, v) if write_cookie?(v) }
@delete_cookies.each { |k, v| ::Rack::Utils.delete_cookie_header!(headers, k, v) } @delete_cookies.each { |k, v| ::Rack::Utils.delete_cookie_header!(headers, k, v) }
Expand Down Expand Up @@ -273,22 +298,51 @@ def []=(key, options)
@parent_jar[key] = options @parent_jar[key] = options
end end


def signed
@signed ||= SignedCookieJar.new(self, @secret)
end

def method_missing(method, *arguments, &block) def method_missing(method, *arguments, &block)
@parent_jar.send(method, *arguments, &block) @parent_jar.send(method, *arguments, &block)
end end
end end


class SignedCookieJar < CookieJar #:nodoc: # Base class for the signed and encrypted cookie jar.
class SecretCookieJar < CookieJar #:nodoc:
MAX_COOKIE_SIZE = 4096 # Cookies can typically store 4096 bytes. MAX_COOKIE_SIZE = 4096 # Cookies can typically store 4096 bytes.
SECRET_MIN_LENGTH = 30 # Characters SECRET_MIN_LENGTH = 30 # Characters


def initialize(parent_jar, secret) def initialize(parent_jar, secret)
ensure_secret_secure(secret) ensure_secret_secure(secret)
@parent_jar = parent_jar @parent_jar = parent_jar
end


def method_missing(method, *arguments, &block)
@parent_jar.send(method, *arguments, &block)
end

protected

# To prevent users from using something insecure like "Password" we make sure that the
# secret they've provided is at least 30 characters in length.
def ensure_secret_secure(secret)
if secret.blank?
raise ArgumentError, "A secret is required to generate an " +
"integrity hash for cookie session data. Use " +
"config.secret_token = \"some secret phrase of at " +
"least #{SECRET_MIN_LENGTH} characters\"" +
"in config/initializers/secret_token.rb"
end

if secret.length < SECRET_MIN_LENGTH
raise ArgumentError, "Secret should be something secure, " +
"like \"#{SecureRandom.hex(16)}\". The value you " +
"provided, \"#{secret}\", is shorter than the minimum length " +
"of #{SECRET_MIN_LENGTH} characters"
end
end
end

class SignedCookieJar < SecretCookieJar #:nodoc:
def initialize(parent_jar, secret)
super(parent_jar, secret)
@verifier = ActiveSupport::MessageVerifier.new(secret) @verifier = ActiveSupport::MessageVerifier.new(secret)
end end


Expand All @@ -311,30 +365,34 @@ def []=(key, options)
raise CookieOverflow if options[:value].size > MAX_COOKIE_SIZE raise CookieOverflow if options[:value].size > MAX_COOKIE_SIZE
@parent_jar[key] = options @parent_jar[key] = options
end end
end


def method_missing(method, *arguments, &block) class EncryptedCookieJar < SecretCookieJar #:nodoc:
@parent_jar.send(method, *arguments, &block) def initialize(parent_jar, secret)
super(parent_jar, secret)
@encrypter = ActiveSupport::MessageEncryptor.new(secret)
end end


protected def [](name)

if encrypted_message = @parent_jar[name]
# To prevent users from using something insecure like "Password" we make sure that the @encrypter.decrypt_and_verify(encrypted_message)
# secret they've provided is at least 30 characters in length.
def ensure_secret_secure(secret)
if secret.blank?
raise ArgumentError, "A secret is required to generate an " +
"integrity hash for cookie session data. Use " +
"config.secret_token = \"some secret phrase of at " +
"least #{SECRET_MIN_LENGTH} characters\"" +
"in config/initializers/secret_token.rb"
end end
rescue ActiveSupport::MessageVerifier::InvalidSignature
nil
rescue ActiveSupport::MessageEncryptor::InvalidMessage
nil
end


if secret.length < SECRET_MIN_LENGTH def []=(key, options)
raise ArgumentError, "Secret should be something secure, " + if options.is_a?(Hash)
"like \"#{SecureRandom.hex(16)}\". The value you " + options.symbolize_keys!
"provided, \"#{secret}\", is shorter than the minimum length " + options[:value] = @encrypter.encrypt_and_sign(options[:value])
"of #{SECRET_MIN_LENGTH} characters" else
options = { :value => @encrypter.encrypt_and_sign(options) }
end end

raise CookieOverflow if options[:value].size > MAX_COOKIE_SIZE
@parent_jar[key] = options
end end
end end


Expand Down
69 changes: 66 additions & 3 deletions actionpack/test/dispatch/cookies_test.rb
Expand Up @@ -63,6 +63,11 @@ def set_signed_cookie
head :ok head :ok
end end


def set_encrypted_cookie
cookies.encrypted[:treasure_map] = "X marks the spot"
head :ok
end

def raise_data_overflow def raise_data_overflow
cookies.signed[:foo] = 'bye!' * 1024 cookies.signed[:foo] = 'bye!' * 1024
head :ok head :ok
Expand All @@ -74,11 +79,22 @@ def tampered_cookies
head :ok head :ok
end end


def tampered_encrypted_cookies
cookies[:tampered_encrypted] = "BAhJIlBzZGZhc2YxeUd2NmFJZTluZUVpdFZVck1BZDV0KytnS1JJVXBsU0RXUmpBTDdIRms9LS02ZGczb04vRGpsMyswN0xnWjJmeExRPT0GOgZFVA%3D%3D--637b89be586e940f9c3e25ef51f1e6d0dc9f6251"
cookies.encrypted[:tampered_encrypted]
head :ok
end

def set_permanent_signed_cookie def set_permanent_signed_cookie
cookies.permanent.signed[:remember_me] = 100 cookies.permanent.signed[:remember_me] = 100
head :ok head :ok
end end


def set_permanent_encrypted_cookie
cookies.permanent.encrypted[:buried_tresure] = "Gold!!!"
head :ok
end

def delete_and_set_cookie def delete_and_set_cookie
cookies.delete :user_name cookies.delete :user_name
cookies[:user_name] = { :value => "david", :expires => Time.utc(2005, 10, 10,5) } cookies[:user_name] = { :value => "david", :expires => Time.utc(2005, 10, 10,5) }
Expand Down Expand Up @@ -272,17 +288,33 @@ def test_signed_cookie
assert_equal 45, @controller.send(:cookies).signed[:user_id] assert_equal 45, @controller.send(:cookies).signed[:user_id]
end end


def test_encrypted_cookie
get :set_encrypted_cookie
assert_equal "X marks the spot", @controller.send(:cookies).encrypted[:treasure_map]
end

def test_accessing_nonexistant_signed_cookie_should_not_raise_an_invalid_signature def test_accessing_nonexistant_signed_cookie_should_not_raise_an_invalid_signature
get :set_signed_cookie get :set_signed_cookie
assert_nil @controller.send(:cookies).signed[:non_existant_attribute] assert_nil @controller.send(:cookies).signed[:non_existant_attribute]
end end


def test_accessing_nonexistant_encrypted_cookie_should_not_raise_an_invalid_signature
get :set_encrypted_cookie
assert_nil @controller.send(:cookies).encrypted[:non_existant_attribute]
end

def test_permanent_signed_cookie def test_permanent_signed_cookie
get :set_permanent_signed_cookie get :set_permanent_signed_cookie
assert_match(%r(#{20.years.from_now.utc.year}), @response.headers["Set-Cookie"]) assert_match(%r(#{20.years.from_now.utc.year}), @response.headers["Set-Cookie"])
assert_equal 100, @controller.send(:cookies).signed[:remember_me] assert_equal 100, @controller.send(:cookies).signed[:remember_me]
end end


def test_permanent_encrypted_cookie
get :set_permanent_encrypted_cookie
assert_match(%r(#{20.years.from_now.utc.year}), @response.headers["Set-Cookie"])
assert_equal "Gold!!!", @controller.send(:cookies).encrypted[:buried_tresure]
end

def test_delete_and_set_cookie def test_delete_and_set_cookie
get :delete_and_set_cookie get :delete_and_set_cookie
assert_cookie_header "user_name=david; path=/; expires=Mon, 10-Oct-2005 05:00:00 GMT" assert_cookie_header "user_name=david; path=/; expires=Mon, 10-Oct-2005 05:00:00 GMT"
Expand All @@ -302,6 +334,13 @@ def test_tampered_cookies
end end
end end


def test_tampered_encrypted_cookies
assert_nothing_raised do
get :tampered_encrypted_cookies
assert_response :success
end
end

def test_raises_argument_error_if_missing_secret def test_raises_argument_error_if_missing_secret
assert_raise(ArgumentError, nil.inspect) { assert_raise(ArgumentError, nil.inspect) {
@request.env["action_dispatch.secret_token"] = nil @request.env["action_dispatch.secret_token"] = nil
Expand All @@ -312,6 +351,16 @@ def test_raises_argument_error_if_missing_secret
@request.env["action_dispatch.secret_token"] = "" @request.env["action_dispatch.secret_token"] = ""
get :set_signed_cookie get :set_signed_cookie
} }

assert_raise(ArgumentError, nil.inspect) {
@request.env["action_dispatch.secret_token"] = nil
get :set_encrypted_cookie
}

assert_raise(ArgumentError, ''.inspect) {
@request.env["action_dispatch.secret_token"] = ""
get :set_encrypted_cookie
}
end end


def test_raises_argument_error_if_secret_is_probably_insecure def test_raises_argument_error_if_secret_is_probably_insecure
Expand All @@ -329,6 +378,21 @@ def test_raises_argument_error_if_secret_is_probably_insecure
@request.env["action_dispatch.secret_token"] = "12345678901234567890123456789" @request.env["action_dispatch.secret_token"] = "12345678901234567890123456789"
get :set_signed_cookie get :set_signed_cookie
} }

assert_raise(ArgumentError, "password".inspect) {
@request.env["action_dispatch.secret_token"] = "password"
get :set_encrypted_cookie
}

assert_raise(ArgumentError, "secret".inspect) {
@request.env["action_dispatch.secret_token"] = "secret"
get :set_encrypted_cookie
}

assert_raise(ArgumentError, "12345678901234567890123456789".inspect) {
@request.env["action_dispatch.secret_token"] = "12345678901234567890123456789"
get :set_encrypted_cookie
}
end end


def test_cookie_with_all_domain_option def test_cookie_with_all_domain_option
Expand Down Expand Up @@ -463,8 +527,6 @@ def test_cookies_hash_is_indifferent_access
assert_equal "dhh", cookies['user_name'] assert_equal "dhh", cookies['user_name']
end end




def test_setting_request_cookies_is_indifferent_access def test_setting_request_cookies_is_indifferent_access
cookies.clear cookies.clear
cookies[:user_name] = "andrew" cookies[:user_name] = "andrew"
Expand Down Expand Up @@ -575,4 +637,5 @@ def assert_not_cookie_header(expected)
assert_not_equal expected.split("\n"), header assert_not_equal expected.split("\n"), header
end end
end end
end
end