diff --git a/lib/rage/all.rb b/lib/rage/all.rb index b2a30af2..b50e1f43 100644 --- a/lib/rage/all.rb +++ b/lib/rage/all.rb @@ -36,7 +36,3 @@ require_relative "middleware/body_finalizer" require_relative "telemetry/telemetry" - -if defined?(Sidekiq) - require_relative "sidekiq_session" -end diff --git a/lib/rage/cookies.rb b/lib/rage/cookies.rb index c11fa821..cd6b52c0 100644 --- a/lib/rage/cookies.rb +++ b/lib/rage/cookies.rb @@ -6,7 +6,7 @@ if !defined?(DomainName) fail <<~ERR - rage-rb depends on domain_name to specify the domain name for cookies. Add the following line to your Gemfile: + Rage depends on `domain_name` to specify the domain name for cookies. Ensure the following line is added to your Gemfile: gem "domain_name" ERR @@ -250,7 +250,7 @@ def self.dump(value) end class EncryptedJar - SALT = "encrypted cookie" + INFO = "encrypted cookie" PADDING = "00" class << self @@ -266,7 +266,7 @@ def load(value) Rage.logger.debug("Failed to decrypt encrypted cookie") i ||= 0 if (box = fallback_boxes[i]) - Rage.logger.debug("Trying to decrypt with fallback key ##{i + 1}") + Rage.logger.debug { "Trying to decrypt with fallback key ##{i + 1}" } i += 1 retry end @@ -286,7 +286,7 @@ def primary_box if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0")) fail <<~ERR - rage-rb depends on rbnacl [>= 3.3, < 8.0] to encrypt cookies. Add the following line to your Gemfile: + Rage depends on `rbnacl` [>= 3.3, < 8.0] to encrypt cookies. Ensure the following line is added to your Gemfile: gem "rbnacl" ERR @@ -296,17 +296,25 @@ def primary_box raise "Rage.config.secret_key_base should be set to use encrypted cookies" end - RbNaCl::SimpleBox.from_secret_key( - RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: SALT) - ) + RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base)) end end def fallback_boxes - @fallback_boxes ||= Rage.config.fallback_secret_key_base.map do |key| - RbNaCl::SimpleBox.from_secret_key(RbNaCl::Hash.blake2b(key, digest_size: 32, salt: SALT)) + @fallback_boxes ||= begin + fallbacks = Rage.config.fallback_secret_key_base.map do |key| + RbNaCl::SimpleBox.from_secret_key(build_key(key)) + end + + fallbacks << RbNaCl::SimpleBox.from_secret_key( + RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: INFO) + ) end end + + def build_key(secret) + RbNaCl::Hash.blake2b("", key: [secret].pack("H*"), digest_size: 32, personal: INFO) + end end # class << self end end diff --git a/lib/rage/router/backend.rb b/lib/rage/router/backend.rb index 88fa2c17..f388f478 100644 --- a/lib/rage/router/backend.rb +++ b/lib/rage/router/backend.rb @@ -23,28 +23,25 @@ def mount(path, handler, methods) raise ArgumentError, "Mount handler should respond to `call`" unless handler.respond_to?(:call) raw_handler = handler - is_sidekiq = handler.respond_to?(:name) && handler.name == "Sidekiq::Web" + handler = wrap_in_rack_session(handler) if handler.respond_to?(:name) && handler.name == "Sidekiq::Web" + + app = ->(env, _params) do + # rewind `rack.input` in case mounted application needs to access the request body; + # by the time the app is called, `rack.input` is already consumed in `Rage::ParamsParser` + env["rack.input"].rewind - handler = ->(env, _params) do env["SCRIPT_NAME"] = path sub_path = env["PATH_INFO"].delete_prefix!(path) env["PATH_INFO"] = "/" if sub_path == "" - if is_sidekiq - Rage::SidekiqSession.with_session(env) do - raw_handler.call(env) - end - else - raw_handler.call(env) - end - + handler.call(env) ensure env["PATH_INFO"] = "#{env["SCRIPT_NAME"]}#{sub_path}" end methods.each do |method| - __on(method, path, handler, {}, {}, { raw_handler:, mount: true }) - __on(method, "#{path}/*", handler, {}, {}, { raw_handler:, mount: true }) + __on(method, path, app, {}, {}, { raw_handler:, mount: true }) + __on(method, "#{path}/*", app, {}, {}, { raw_handler:, mount: true }) end end @@ -278,4 +275,32 @@ def find(env, derived_constraints) end end end + + def wrap_in_rack_session(handler) + unless defined?(Rack::Session::Cookie) + fail <<~ERR + + `#{handler.name}` depends on `Rack::Session`. Ensure the following line is added to your Gemfile: + gem "rack-session" + + ERR + end + + secret_key = if Rage.config.secret_key_base + require "openssl" + OpenSSL::KDF.hkdf( + [Rage.config.secret_key_base].pack("H*"), + salt: "rack.session", + info: handler.name, + length: 64, + hash: "SHA256" + ) + else + puts "WARNING: `secret_key_base` is not set. Using a temporary random secret for `#{handler.name}` sessions. Sessions will not persist across server restarts." + require "securerandom" + SecureRandom.random_bytes(64) + end + + Rack::Session::Cookie.new(handler, secret: secret_key, same_site: true, max_age: 86400) + end end diff --git a/lib/rage/sidekiq_session.rb b/lib/rage/sidekiq_session.rb deleted file mode 100644 index 00912fcf..00000000 --- a/lib/rage/sidekiq_session.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "base64" - -## -# Used **specifically** for compatibility with Sidekiq's Web interface. -# Remove once we have real sessions or once Sidekiq's author decides they -# don't need cookie sessions to protect against CSRF. -# -class Rage::SidekiqSession - KEY = Digest::SHA2.hexdigest(ENV["SECRET_KEY_BASE"] || File.read("Gemfile.lock") + File.read("config/routes.rb")) - SESSION_KEY = "rage.sidekiq.session" - - def self.with_session(env) - env["rack.session"] = session = new(env) - response = yield - - if session.changed - Rack::Utils.set_cookie_header!( - response[1], - SESSION_KEY, - { path: env["SCRIPT_NAME"], httponly: true, same_site: true, value: session.dump } - ) - end - - response - end - - attr_reader :changed - - def initialize(env) - @env = env - session = Rack::Utils.parse_cookies(@env)[SESSION_KEY] - @data = decode_session(session) - end - - def [](key) - @data[key] - end - - def[]=(key, value) - @changed = true - @data[key] = value - end - - def to_hash - @data - end - - def dump - encoded_data = Marshal.dump(@data) - signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data) - - Base64.urlsafe_encode64("#{encoded_data}--#{signature}") - end - - private - - def decode_session(session) - return {} unless session - - encoded_data, signature = Base64.urlsafe_decode64(session).split("--") - ref_signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data) - - if Rack::Utils.secure_compare(signature, ref_signature) - Marshal.load(encoded_data) - else - {} - end - end -end diff --git a/spec/controller/api/cookies_spec.rb b/spec/controller/api/cookies_spec.rb index aca135fa..cd9b1239 100644 --- a/spec/controller/api/cookies_spec.rb +++ b/spec/controller/api/cookies_spec.rb @@ -21,15 +21,15 @@ context "with cookies" do before do - allow(Rage.config).to receive(:secret_key_base).and_return("rage-test-key") - allow(Rage.config).to receive(:fallback_secret_key_base).and_return(%w(rage-fallback-key)) + allow(Rage.config).to receive(:secret_key_base).and_return("b7ef8f0824ffbddb85818fb6898546a1") + allow(Rage.config).to receive(:fallback_secret_key_base).and_return(%w(707ae8b7c9655bd5cd30fd407d2791e4)) end let(:cookies) do { user_id: 112, callback_url: "https://test-host.com", - session: "MDB4tHk-Qm-da9_zeXE8NaywvyzidycD4qIhK1xWmYVJE_jYeuo_rzd9Eb6gwCLBn8eYaqT9QBomfAFA" + session: "MDAAeSoXJxjazIR1ER55uE2KYYT5Bwabdws3Mu_SSgt563O6VE9dGGoSYjTQ_ShcJKWmymNAQpFG-Mg5" } end @@ -71,7 +71,7 @@ context "with data encrypted with rotated key" do let(:cookies) do - { session: "MDC9exZmtbHuQ0hKQbfuf69gBQE0oER1y0DInAq686395nMYPVRxt0D3W8wt0jjegw1LNu4MSvQf1LSWdw==" } + { session: "MDBDgi9VGfN221gVpLfGx3Hs_IwW5cdjxAd7U-uBFSwxYthYMY32WjTW_C3e70rZthg1R936g8Jhwe59wg==" } end before do @@ -83,8 +83,22 @@ end end + context "with legacy key" do + let(:cookies) do + { session: "MDDPbSUSGqXtww1LMHHcCSiYFE_EMswmzJRbs0koNSFUz9CHbHR-wAwv7vDj5MJFom_7XGwB-FNW5M8P-0K4uw==" } + end + + before do + allow(Rage).to receive(:logger).and_return(double(debug: nil)) + end + + it "correctly decrypts data" do + expect(subject.cookies.encrypted[:session]).to eq("primary-old-test-value") + end + end + context "with incorrectly encrypted data" do - let(:cookies) { { session: "MDC9exZmtbHuQ0hK" } } + let(:cookies) { { session: "MDBDgi9VGfN221gVpLfGx3Hs" } } before do allow(Rage).to receive(:logger).and_return(double(debug: nil)) @@ -228,7 +242,7 @@ end it "correctly sets permanent cookies with encrypted values" do - allow(Rage.config).to receive(:secret_key_base).and_return("rage-test-key") + allow(Rage.config).to receive(:secret_key_base).and_return("b7ef8f0824ffbddb85818fb6898546a1") subject.cookies.encrypted.permanent[:user_id] = "secret" expect(response_cookies[:user_id]).to match(/\S+; expires=\w{3}, \d{2} \w{3} #{Time.now.year + 20} \d{2}:\d{2}:\d{2} GMT/) diff --git a/spec/controller/api/session_spec.rb b/spec/controller/api/session_spec.rb index 1b86296e..d1570588 100644 --- a/spec/controller/api/session_spec.rb +++ b/spec/controller/api/session_spec.rb @@ -10,11 +10,11 @@ let(:headers) { { "HTTP_COOKIE" => "#{Rage::Session.key}=#{encoded_session}" } } before do - allow(Rage.config).to receive(:secret_key_base).and_return("rage-test-key") + allow(Rage.config).to receive(:secret_key_base).and_return("b7ef8f0824ffbddb85818fb6898546a1") end context "when reading a valid session" do - let(:encoded_session) { "MDDTFjPTyaIdJjZG2C-RJmDPC_5fMyBMTn87Dv7EID3g-OJwakyxFQUhoSlxwqdLRw4npvm08F0=" } + let(:encoded_session) { "MDCf27lpe1B7qJB-1bZpqzuZ-0Tf2i0YocehRwCz5cnm6ktYi0djcr-zDtgZZQFyUhp4Q-zPM_8=" } it "correctly reads values" do expect(subject.session[:a]).to eq(1) diff --git a/spec/router/mount_spec.rb b/spec/router/mount_spec.rb index be945116..ac466430 100644 --- a/spec/router/mount_spec.rb +++ b/spec/router/mount_spec.rb @@ -46,4 +46,60 @@ router.mount("/rack_app", 5, %w(GET)) }.to raise_error(/should respond to `call`/) end + + context "with session" do + let(:app) do + Class.new do + def self.name + "Sidekiq::Web" + end + + def self.call(env) + end + end + end + + context "with Rack::Session available" do + let(:session) do + Class.new do + def initialize(app, **) + @app = app + end + + def call(env) + :test_session + end + end + end + + before do + allow(Rage).to receive(:config).and_return(double(secret_key_base: "test secret")) + stub_const("Rack::Session::Cookie", session) + end + + it "exposes session object" do + router.mount("/test", app, %w(GET)) + + response, _ = perform_get_request("/test") + expect(response).to eq(:test_session) + end + + it "rewinds request body" do + router.mount("/test", app, %w(GET)) + + body = StringIO.new("test body").tap(&:read) + perform_get_request("/test", body:) + + expect(body.read).to eq("test body") + end + end + + context "with Rack::Session unavailable" do + it "raises exception" do + expect { + router.mount("/test", app, %w(GET)) + }.to raise_error(/`Sidekiq::Web` depends on `Rack::Session`/) + end + end + end end diff --git a/spec/support/request_helper.rb b/spec/support/request_helper.rb index 8b117745..8c550f1c 100644 --- a/spec/support/request_helper.rb +++ b/spec/support/request_helper.rb @@ -5,25 +5,26 @@ def router @router ||= Rage::Router::Backend.new end - def perform_get_request(path, host: nil, params: {}) - perform_request("GET", path, host, params) + def perform_get_request(path, host: nil, params: {}, body: nil) + perform_request("GET", path, host, params, body) end - def perform_head_request(path, host: nil, params: {}) - perform_request("HEAD", path, host, params) + def perform_head_request(path, host: nil, params: {}, body: nil) + perform_request("HEAD", path, host, params, body) end - def perform_post_request(path, host: nil, params: {}) - perform_request("POST", path, host, params) + def perform_post_request(path, host: nil, params: {}, body: nil) + perform_request("POST", path, host, params, body) end private - def perform_request(method, path, host, params) + def perform_request(method, path, host, params, body) env = { "REQUEST_METHOD" => method, "PATH_INFO" => path, - "HTTP_HOST" => host + "HTTP_HOST" => host, + "rack.input" => body || StringIO.new } handler = router.lookup(env)