diff options
Diffstat (limited to 'actionpack/lib/action_dispatch/middleware/ssl.rb')
| -rw-r--r-- | actionpack/lib/action_dispatch/middleware/ssl.rb | 149 |
1 files changed, 113 insertions, 36 deletions
diff --git a/actionpack/lib/action_dispatch/middleware/ssl.rb b/actionpack/lib/action_dispatch/middleware/ssl.rb index 0c7caef25d..ef633aadc6 100644 --- a/actionpack/lib/action_dispatch/middleware/ssl.rb +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -1,72 +1,149 @@ +# frozen_string_literal: true + module ActionDispatch + # This middleware is added to the stack when <tt>config.force_ssl = true</tt>, and is passed + # the options set in +config.ssl_options+. It does three jobs to enforce secure HTTP + # requests: + # + # 1. <b>TLS redirect</b>: Permanently redirects +http://+ requests to +https://+ + # with the same URL host, path, etc. Enabled by default. Set +config.ssl_options+ + # to modify the destination URL + # (e.g. <tt>redirect: { host: "secure.widgets.com", port: 8080 }</tt>), or set + # <tt>redirect: false</tt> to disable this feature. + # + # Requests can opt-out of redirection with +exclude+: + # + # config.ssl_options = { redirect: { exclude: -> request { request.path =~ /healthcheck/ } } } + # + # 2. <b>Secure cookies</b>: Sets the +secure+ flag on cookies to tell browsers they + # must not be sent along with +http://+ requests. Enabled by default. Set + # +config.ssl_options+ with <tt>secure_cookies: false</tt> to disable this feature. + # + # 3. <b>HTTP Strict Transport Security (HSTS)</b>: Tells the browser to remember + # this site as TLS-only and automatically redirect non-TLS requests. + # Enabled by default. Configure +config.ssl_options+ with <tt>hsts: false</tt> to disable. + # + # Set +config.ssl_options+ with <tt>hsts: { ... }</tt> to configure HSTS: + # + # * +expires+: How long, in seconds, these settings will stick. The minimum + # required to qualify for browser preload lists is 18 weeks. Defaults to + # 180 days (recommended). + # + # * +subdomains+: Set to +true+ to tell the browser to apply these settings + # to all subdomains. This protects your cookies from interception by a + # vulnerable site on a subdomain. Defaults to +true+. + # + # * +preload+: Advertise that this site may be included in browsers' + # preloaded HSTS lists. HSTS protects your site on every visit <i>except the + # first visit</i> since it hasn't seen your HSTS header yet. To close this + # gap, browser vendors include a baked-in list of HSTS-enabled sites. + # Go to https://hstspreload.org to submit your site for inclusion. + # Defaults to +false+. + # + # To turn off HSTS, omitting the header is not enough. Browsers will remember the + # original HSTS directive until it expires. Instead, use the header to tell browsers to + # expire HSTS immediately. Setting <tt>hsts: false</tt> is a shortcut for + # <tt>hsts: { expires: 0 }</tt>. class SSL - YEAR = 31536000 + # :stopdoc: + + # Default to 180 days, the low end for https://www.ssllabs.com/ssltest/ + # and greater than the 18-week requirement for browser preload lists. + HSTS_EXPIRES_IN = 15552000 def self.default_hsts_options - { :expires => YEAR, :subdomains => false } + { expires: HSTS_EXPIRES_IN, subdomains: true, preload: false } end - def initialize(app, options = {}) + def initialize(app, redirect: {}, hsts: {}, secure_cookies: true) @app = app - @hsts = options.fetch(:hsts, {}) - @hsts = {} if @hsts == true - @hsts = self.class.default_hsts_options.merge(@hsts) if @hsts + @redirect = redirect + + @exclude = @redirect && @redirect[:exclude] || proc { !@redirect } + @secure_cookies = secure_cookies - @host = options[:host] - @port = options[:port] + @hsts_header = build_hsts_header(normalize_hsts_options(hsts)) end def call(env) - request = Request.new(env) + request = Request.new env if request.ssl? - status, headers, body = @app.call(env) - headers = hsts_headers.merge(headers) - flag_cookies_as_secure!(headers) - [status, headers, body] + @app.call(env).tap do |status, headers, body| + set_hsts_header! headers + flag_cookies_as_secure! headers if @secure_cookies + end else - redirect_to_https(request) + return redirect_to_https request unless @exclude.call(request) + @app.call(env) end end private - def redirect_to_https(request) - host = @host || request.host - port = @port || request.port - - location = "https://#{host}" - location << ":#{port}" if port != 80 - location << request.fullpath - - headers = { 'Content-Type' => 'text/html', 'Location' => location } - - [301, headers, []] + def set_hsts_header!(headers) + headers["Strict-Transport-Security".freeze] ||= @hsts_header end - # http://tools.ietf.org/html/draft-hodges-strict-transport-sec-02 - def hsts_headers - if @hsts - value = "max-age=#{@hsts[:expires].to_i}" - value += "; includeSubDomains" if @hsts[:subdomains] - { 'Strict-Transport-Security' => value } + def normalize_hsts_options(options) + case options + # Explicitly disabling HSTS clears the existing setting from browsers + # by setting expiry to 0. + when false + self.class.default_hsts_options.merge(expires: 0) + # Default to enabled, with default options. + when nil, true + self.class.default_hsts_options else - {} + self.class.default_hsts_options.merge(options) end end + # https://tools.ietf.org/html/rfc6797#section-6.1 + def build_hsts_header(hsts) + value = "max-age=#{hsts[:expires].to_i}".dup + value << "; includeSubDomains" if hsts[:subdomains] + value << "; preload" if hsts[:preload] + value + end + def flag_cookies_as_secure!(headers) - if cookies = headers['Set-Cookie'] - cookies = cookies.split("\n") + if cookies = headers["Set-Cookie".freeze] + cookies = cookies.split("\n".freeze) - headers['Set-Cookie'] = cookies.map { |cookie| + headers["Set-Cookie".freeze] = cookies.map { |cookie| if cookie !~ /;\s*secure\s*(;|$)/i "#{cookie}; secure" else cookie end - }.join("\n") + }.join("\n".freeze) end end + + def redirect_to_https(request) + [ @redirect.fetch(:status, redirection_status(request)), + { "Content-Type" => "text/html", + "Location" => https_location_for(request) }, + @redirect.fetch(:body, []) ] + end + + def redirection_status(request) + if request.get? || request.head? + 301 # Issue a permanent redirect via a GET request. + else + 307 # Issue a fresh request redirect to preserve the HTTP method. + end + end + + def https_location_for(request) + host = @redirect[:host] || request.host + port = @redirect[:port] || request.port + + location = "https://#{host}".dup + location << ":#{port}" if port != 80 && port != 443 + location << request.fullpath + location + end end end |
