lib/safe_cookies.rb in safe_cookies-0.1.3 vs lib/safe_cookies.rb in safe_cookies-0.1.4

- old
+ new

@@ -1,92 +1,104 @@ # -*- encoding: utf-8 -*- +require "safe_cookies/configuration" +require "safe_cookies/cookie_path_fix" +require "safe_cookies/util" require "safe_cookies/version" require "rack" module SafeCookies - class Middleware + UnknownCookieError = Class.new(StandardError) + + CACHE_COOKIE_NAME = '_safe_cookies__known_cookies' + SECURED_COOKIE_NAME = 'secured_old_cookies' + HELPER_COOKIES_LIFETIME = 10 * 365 * 24 * 60 * 60 # 10 years - def initialize(app, options = {}) - # Pass a hash for `cookies_to_update` with name as key and lifetime as value. - # Use this to update existing cookies that you expect to receive. - # - # Unfortunately, the client won't ever tell us if the cookie was originally - # sent with flags such as "secure" or which expiry date it currently has: - # http://tools.ietf.org/html/rfc6265#section-4.2.2 - # - # The :non_secure option specifies cookies that will not be made secure. Use - # this for storing site usage settings like filters etc. that need to be available - # when not on HTTPS (this should rarely be the case). - # - # The :non_http_only option is analog, use it for storing data you want to access - # with javascript. + class Middleware + + include CookiePathFix + + KNOWN_COOKIES_DIVIDER = '|' - options = options.dup + def initialize(app) @app = app - @non_secure = (options.delete(:non_secure) || []).map(&:to_s) - @non_http_only = (options.delete(:non_http_only) || []).map(&:to_s) - @cookies_to_update = options + @configuration = SafeCookies.configuration or raise "Don't know what to do without configuration" end def call(env) - @env = env - status, headers, body = @app.call(@env) + reset_instance_variables + + @request = Rack::Request.new(env) + ensure_no_unknown_cookies! - secure_old_cookies!(headers) if @cookies_to_update.any? - secure_new_cookies!(headers) + status, @headers, body = @app.call(env) - [ status, headers, body ] + fix_cookie_paths if fix_cookie_paths? + rewrite_request_cookies unless cookies_have_been_rewritten_before + cache_application_cookies + rewrite_application_cookies + + [ status, @headers, body ] end private + + def reset_instance_variables + @request, @headers = nil + end def secure(cookie) # Regexp from https://github.com/tobmatth/rack-ssl-enforcer/ - if secure?(cookie) and cookie !~ /(^|;\s)secure($|;)/ + if should_be_secure?(cookie) and cookie !~ /(^|;\s)secure($|;)/ "#{cookie}; secure" else cookie end end def http_only(cookie) - if http_only?(cookie) and cookie !~ /(^|;\s)HttpOnly($|;)/ + if should_be_http_only?(cookie) and cookie !~ /(^|;\s)HttpOnly($|;)/ "#{cookie}; HttpOnly" else cookie end end + + # This method takes all cookies sent with the request and rewrites them, + # making them both secure and http-only (unless specified otherwise in + # the configuration). + # With the SECURED_COOKIE_NAME cookie we remember the exact time that we + # rewrote the cookies. + def rewrite_request_cookies + if @request.cookies.any? + registered_cookies_in_request.each do |registered_cookie, options| + value = @request.cookies[registered_cookie] - def secure_old_cookies!(headers) - request = Rack::Request.new(@env) - return if request.cookies['secured_old_cookies'] - - @cookies_to_update.each do |key, expiry| - key = key.to_s - if request.cookies.has_key?(key) - value = request.cookies[key] - set_secure_cookie!(headers, key, value, expiry) + set_cookie!(registered_cookie, value, options) end + + formatted_now = Rack::Utils.rfc2822(Time.now.gmtime) + set_cookie!(SECURED_COOKIE_NAME, formatted_now, :expire_after => HELPER_COOKIES_LIFETIME) end - set_secure_cookie!(headers, 'secured_old_cookies', Rack::Utils.rfc2822(Time.now.gmtime)) end - def set_secure_cookie!(headers, key, value, expire_after = 365 * 24 * 60 * 60) # one year - options = { - :path => '/', - :value => value, - :secure => secure?(key), - :httponly => http_only?(key), - :expires => Time.now + expire_after # This is what Rails does - } - Rack::Utils.set_cookie_header!(headers, key, options) + def set_cookie!(name, value, options) + options = options.dup + expire_after = options.delete(:expire_after) + + options[:expires] = Time.now + expire_after if expire_after + options[:path] = '/' unless options.has_key?(:path) # allow setting path = nil + options[:value] = value + options[:secure] = should_be_secure?(name) + options[:httponly] = should_be_http_only?(name) + + Rack::Utils.set_cookie_header!(@headers, name, options) end - def secure_new_cookies!(headers) - cookies = headers['Set-Cookie'] + def rewrite_application_cookies + cookies = @headers['Set-Cookie'] if cookies # Rails 2.3 / Rack 1.1 offers an array which is actually nice. cookies = cookies.split("\n") unless cookies.is_a?(Array) # On Rack 1.1, cookie values sometimes contain trailing newlines. @@ -101,25 +113,75 @@ # Unfortunately there is no pretty way to touch a "Set-Cookie" header. # It contains more information than the "HTTP_COOKIE" header from the # browser's request contained, so a `Rack::Request` can't parse it for # us. A `Rack::Response` doesn't offer a way either. - headers['Set-Cookie'] = cookies.join("\n") + @headers['Set-Cookie'] = cookies.join("\n") end end - def secure?(cookie) - name = cookie.split('=').first.strip - ssl_request? and not @non_secure.include?(name) + def should_be_secure?(cookie) + cookie_name = cookie.split('=').first.strip + ssl? and not @configuration.insecure_cookie?(cookie_name) end - def http_only?(cookie) - name = cookie.split('=').first.strip - not @non_http_only.include?(name) + def ssl? + if @request.respond_to?(:ssl?) + @request.ssl? + else + # older Rack versions + @request.scheme == 'https' + end end - def ssl_request? - @env['HTTPS'] == 'on' || @env['HTTP_X_FORWARDED_PROTO'] == 'https' # this is how Rails does it + def should_be_http_only?(cookie) + cookie_name = cookie.split('=').first.strip + not @configuration.scriptable_cookie?(cookie_name) + end + + def ensure_no_unknown_cookies! + request_cookies = @request.cookies.keys.map(&:to_s) + unknown_cookies = request_cookies - known_cookies + + if unknown_cookies.any? + handle_unknown_cookies(unknown_cookies) + end + end + + def handle_unknown_cookies(cookies) + raise SafeCookies::UnknownCookieError.new("Request for '#{@request.url}' had unknown cookies: #{cookies.join(', ')}") + end + + def cache_application_cookies + new_application_cookies = @headers['Set-Cookie'] + + if new_application_cookies + new_application_cookies = new_application_cookies.join("\n") if new_application_cookies.is_a?(Array) + application_cookies = cached_application_cookies + new_application_cookies.scan(/(?=^|\n)[^\n;,=]+/i) + application_cookies_string = application_cookies.uniq.join(KNOWN_COOKIES_DIVIDER) + + set_cookie!(CACHE_COOKIE_NAME, application_cookies_string, :expire_after => HELPER_COOKIES_LIFETIME) + end + end + + def cached_application_cookies + cache_cookie = @request.cookies[CACHE_COOKIE_NAME] || "" + cache_cookie.split(KNOWN_COOKIES_DIVIDER) + end + + def known_cookies + known = [CACHE_COOKIE_NAME, SECURED_COOKIE_NAME] + known += cached_application_cookies + known += @configuration.registered_cookies.keys + end + + def cookies_have_been_rewritten_before + @request.cookies.has_key? SECURED_COOKIE_NAME + end + + # returns those of the registered cookies that appear in the request + def registered_cookies_in_request + Util.slice(@configuration.registered_cookies, *@request.cookies.keys) end end end