# frozen_string_literal: true
class Tynn
# Enforces secure HTTP requests by:
#
# 1. Redirecting HTTP requests to their HTTPS counterparts.
#
# 2. Setting the HTTP Strict-Transport-Security header (HSTS).
# This ensures the browser never visits the http version of a website.
# This reduces the impact of leaking session data through cookies
# and external links, and defends against Man-in-the-middle attacks.
#
# 3. Setting the secure flag on cookies. This tells the browser to
# only transmit them over HTTPS.
#
# You can configure HSTS passing a :hsts option. The following options
# are supported:
#
# - *:expires* - The time, in seconds, that the browser access the site only
# by HTTPS. Defaults to 180 days.
#
# - *:subdomains* - If this is true, the rule applies to all the
# site's subdomains as well. Defaults to true.
#
# - *:preload* - A limitation of HSTS is that the initial request remains
# unprotected if it uses HTTP. The same applies to the first request after
# the activity period specified by max-age. Modern browsers implements
# a "HSTS preload list", which contains known sites supporting HSTS. If you
# would like to include your website into the list, set this option to
# true and submit your domain to this form[https://hstspreload.appspot.com/].
# Supported by Chrome, Firefox, IE11+ and IE Edge.
#
# To disable HSTS, you will need to tell the browser to expire it immediately.
# Setting hsts: false is a shortcut for hsts: { expires: 0 }.
#
# require "tynn"
# require "tynn/ssl"
# require "tynn/test"
#
# Tynn.plugin(Tynn::SSL)
#
# Tynn.define { }
#
# app = Tynn::Test.new
# app.get("/", {}, "HTTP_HOST" => "tynn.xyz")
#
# app.res.status # => 301
# app.res.location # => "https://tynn.xyz/"
#
# # Using different HSTS options
# Tynn.plugin(
# Tynn::SSL,
# hsts: {
# expires: 31_536_000,
# includeSubdomains: false,
# preload: true
# }
# )
#
# # Disabling HSTS
# Tynn.plugin(Tynn::SSL, hsts: false)
#
module SSL
def self.setup(app, hsts: {}) # :nodoc:
app.use(Tynn::SSL::Middleware, hsts: hsts)
end
class Middleware # :nodoc:
HSTS_MAX_AGE = 15_552_000 # 180 days
def initialize(app, hsts: {})
@app = app
@hsts_header = build_hsts_header(hsts || { expires: 0 })
end
def call(env)
request = Rack::Request.new(env)
return redirect_to_https(request) unless request.ssl?
@app.call(env).tap do |_, headers, _|
set_hsts_header!(headers)
flag_cookies_as_secure!(headers)
end
end
private
def build_hsts_header(options)
header = sprintf("max-age=%i", options.fetch(:expires, HSTS_MAX_AGE))
header << "; includeSubdomains" if options.fetch(:subdomains, true)
header << "; preload" if options[:preload]
header
end
def redirect_to_https(request)
host = request.host
port = request.port
location = "https://" + host
location << ":#{ port }" if port != 80 && port != 443
location << request.fullpath
[301, { "Location" => location }, []]
end
def set_hsts_header!(headers)
headers["Strict-Transport-Security"] ||= @hsts_header
end
def flag_cookies_as_secure!(headers)
return unless cookies = headers["Set-Cookie"]
headers["Set-Cookie"] = cookies.split("\n").map do |cookie|
cookie << "; secure" if cookie !~ /;\s*secure\s*(;|$)/i
cookie
end.join("\n")
end
end
end
end