From b5da06a2c2ea03176c1104a006f49ecccd51240d Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Wed, 18 Oct 2023 15:07:26 -0700 Subject: [PATCH] Add redirect_http_to_https plugin, helping to ensure future requests from the browser are submitted via HTTPS --- CHANGELOG | 4 + lib/roda/plugins/redirect_http_to_https.rb | 99 ++++++++++++++++++++++ spec/plugin/redirect_http_to_https_spec.rb | 95 +++++++++++++++++++++ www/pages/documentation.erb | 1 + 4 files changed, 199 insertions(+) create mode 100644 lib/roda/plugins/redirect_http_to_https.rb create mode 100644 spec/plugin/redirect_http_to_https_spec.rb diff --git a/CHANGELOG b/CHANGELOG index f4c4921c..d2083d93 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ += master + +* Add redirect_http_to_https plugin, helping to ensure future requests from the browser are submitted via HTTPS (jeremyevans) + = 3.73.0 (2023-10-13) * Support :next_if_not_found option for middleware plugin (jeremyevans) (#334) diff --git a/lib/roda/plugins/redirect_http_to_https.rb b/lib/roda/plugins/redirect_http_to_https.rb new file mode 100644 index 00000000..e939dd1d --- /dev/null +++ b/lib/roda/plugins/redirect_http_to_https.rb @@ -0,0 +1,99 @@ +# frozen-string-literal: true + +# +class Roda + module RodaPlugins + # The redirect_http_to_https plugin exposes a +redirect_http_to_https+ + # request method that redirects HTTP requests to HTTPS, helping to ensure + # that future requests by the same browser will be submitted securely. + # + # You should use this plugin if you have an application that can receive + # requests using both HTTP and HTTPS, and you want to make sure that all + # or a subset of routes are only handled for HTTPS requests. + # + # The reason this exposes a request method is so that you can choose where + # in your routing tree to do the redirection: + # + # route do |r| + # # routes available via both HTTP and HTTPS + # r.redirect_http_to_https + # # routes available only via HTTPS + # end + # + # If you want to redirect to HTTPS for all routes in the routing tree, you + # can have this as the very first method call in the routing tree. Note that + # in Roda it is possible to handle routing before the normal routing tree + # using before hooks. The static_routing and heartbeat plugins use this + # feature. If you would like to handle routes before the normal routing tree, + # you can setup a before hook: + # + # plugin :hooks + # + # before do + # request.redirect_http_to_https + # end + module RedirectHttpToHttps + status_map = Hash.new(307) + status_map['GET'] = status_map['HEAD'] = 301 + status_map.freeze + DEFAULTS = {:status_map => status_map}.freeze + private_constant :DEFAULTS + + # Configures redirection from HTTP to HTTPS. Available options: + # + # :body :: The body used in the redirect. If not set, uses an empty body. + # :headers :: Any additional headers used in the redirect response. By default, + # no additional headers are set, the only header used is the Location header. + # :host :: The host to redirect to. If not set, redirects to the same host as the HTTP + # requested to. It is highly recommended that you set this if requests with + # arbitrary Host headers can be submitted to the application. + # :port :: The port to use in the redirect. By default, will not set an explicit port, + # so that it will implicitly use the HTTPS default port of 443. + # :status_map :: A hash mapping request methods to response status codes. By default, + # uses a hash that redirects GET and HEAD requests with a 301 status, + # and other request methods with a 307 status. + def self.configure(app, opts=OPTS) + previous = app.opts[:redirect_http_to_https] || DEFAULTS + opts = app.opts[:redirect_http_to_https] = previous.merge(opts) + opts[:port_string] = opts[:port] ? ":#{opts[:port]}".freeze : "".freeze + opts[:prefix] = opts[:host] ? "https://#{opts[:host]}#{opts[:port_string]}".freeze : nil + opts.freeze + end + + module RequestMethods + # Redirect HTTP requests to HTTPS. While this doesn't secure the + # current request, it makes it more likely that the browser will submit + # future requests securely via HTTPS. + def redirect_http_to_https + return if ssl? + + opts = roda_class.opts[:redirect_http_to_https] + + res = response + + if body = opts[:body] + res.write(body) + end + + if headers = opts[:headers] + res.headers.merge!(headers) + end + + path = if prefix = opts[:prefix] + prefix + fullpath + else + "https://#{host}#{opts[:port_string]}#{fullpath}" + end + + unless status = opts[:status_map][@env['REQUEST_METHOD']] + raise RodaError, "redirect_http_to_https :status_map provided does not support #{@env['REQUEST_METHOD']}" + end + + redirect(path, status) + end + end + end + + register_plugin(:redirect_http_to_https, RedirectHttpToHttps) + end +end diff --git a/spec/plugin/redirect_http_to_https_spec.rb b/spec/plugin/redirect_http_to_https_spec.rb new file mode 100644 index 00000000..a8c09613 --- /dev/null +++ b/spec/plugin/redirect_http_to_https_spec.rb @@ -0,0 +1,95 @@ +require_relative "../spec_helper" + +describe "redirect_http_to_https plugin" do + before do + app(:redirect_http_to_https) do |r| + r.get 'a' do + "a-#{r.ssl?}" + end + + r.redirect_http_to_https + + "x-#{r.ssl?}" + end + end + + it "should not redirect before call to r.redirect_http_to_https" do + body('/a').must_equal 'a-false' + body('/a', 'HTTPS'=>'on').must_equal 'a-true' + end + + it "r.redirect_http_to_https redirects HTTP requests to HTTP" do + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://foo.com/b' + b.must_be_empty + body('/b', 'HTTPS'=>'on').must_equal 'x-true' + end + + it "uses 301 for HEAD redirects by default" do + status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'HEAD').must_equal 301 + end + + it "uses 307 for POST redirects by default" do + status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'POST').must_equal 307 + end + + it "includes query string when redirecting" do + header(RodaResponseHeaders::LOCATION, '/b', 'HTTP_HOST'=>'foo.com', 'QUERY_STRING'=>'foo=bar').must_equal 'https://foo.com/b?foo=bar' + end + + it "supports :body option" do + @app.plugin :redirect_http_to_https, :body=>'RTHS' + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://foo.com/b' + b.must_equal ['RTHS'] + end + + it "supports :headers option" do + @app.plugin :redirect_http_to_https, :headers=>{'foo'=>'bar'} + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://foo.com/b' + h['foo'].must_equal 'bar' + b.must_be_empty + end + + it "supports :host option" do + @app.plugin :redirect_http_to_https, :host=>'bar.foo.com' + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://bar.foo.com/b' + b.must_be_empty + end + + it "supports :port option" do + @app.plugin :redirect_http_to_https, :port=>444 + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://foo.com:444/b' + b.must_be_empty + end + + it "supports :host and :port options together" do + @app.plugin :redirect_http_to_https, :host=>'bar.foo.com', :port=>444 + s, h, b = req('/b', 'HTTP_HOST'=>'foo.com') + s.must_equal 301 + h[RodaResponseHeaders::LOCATION].must_equal 'https://bar.foo.com:444/b' + b.must_be_empty + end + + it "supports :status_map option" do + map = Hash.new(302) + map['GET'] = 301 + @app.plugin :redirect_http_to_https, :status_map=>map + status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'GET').must_equal 301 + status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'HEAD').must_equal 302 + end + + it "raise for :status_map that does not handle request mthod" do + @app.plugin :redirect_http_to_https, :status_map=>{'GET'=>302} + status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'GET').must_equal 302 + proc{status('/b', 'HTTP_HOST'=>'foo.com', 'REQUEST_METHOD'=>'HEAD')}.must_raise Roda::RodaError + end +end diff --git a/www/pages/documentation.erb b/www/pages/documentation.erb index fd4c9b5b..2f798e90 100644 --- a/www/pages/documentation.erb +++ b/www/pages/documentation.erb @@ -108,6 +108,7 @@
  • module_include: Adds request_module and response_module class methods for adding modules/methods to request/response classes.
  • plain_hash_response_headers: Uses plain hashes for response headers on Rack 3, for much better performance.
  • r: Adds r method for accessing the request, useful when r local variable is not in scope.
  • +
  • redirect_http_to_https: Adds request method to redirect HTTP requests to the same location using HTTPS.
  • request_aref: Adds configurable handling for [] and []= request methods.
  • request_headers: Adds a headers method to the request object, for easier access to request headers.
  • response_request: Gives response object access to request object.