ruby / middleware
Cross-cutting concerns in the web framework
are Rack middleware.
Each is a simple class with initialize(app) and call(env).
CSRF
class CSRF
SAFE_METHODS = %w(GET HEAD OPTIONS TRACE).freeze
def initialize(app)
@app = app
end
def call(env)
req = Rack::Request.new(env)
if !SAFE_METHODS.include?(req.request_method)
session_token = req.session[:csrf_token]
param_token = req.params["authenticity_token"]
header_token = env["HTTP_X_CSRF_TOKEN"]
if session_token.nil? || (param_token != session_token && header_token != session_token)
req.session.clear
return [303, {"Location" => "/login"}, []]
end
end
req.session[:csrf_token] ||= SecureRandom.base64(32)
@app.call(env)
end
end
Flash messages
class Flash
def initialize(app)
@app = app
end
def call(env)
req = Rack::Request.new(env)
env["app.flash"] = req.session[:flash] || {}
req.session.delete(:flash)
@app.call(env)
end
end
Flash is read from the session, made available to the handler
via env["app.flash"], then deleted so it only appears once.
Content Security Policy
class CSP
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if headers["Content-Type"].to_s.include?("text/html")
headers["Content-Security-Policy"] = [
"base-uri 'none'",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'"
].join("; ")
end
[status, headers, body]
end
end
HTTPS redirect with HSTS
class HTTPS
def initialize(app)
@app = app
end
def call(env)
scheme = env["HTTP_X_FORWARDED_PROTO"] || Rack::Request.new(env).scheme
if scheme == "http"
url = "https://#{env["HTTP_HOST"]}#{env["REQUEST_URI"]}"
return [301, {"Location" => url}, []]
end
status, headers, body = @app.call(env)
headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
[status, headers, body]
end
end
Error handling
class ErrorHandler
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
rescue => e
Sentry.capture_exception(e)
if ENV["APP_ENV"] == "development"
[500, {"Content-Type" => "text/html"}, [<<~HTML]]
<h1>500</h1>
<pre>#{CGI.escapeHTML(e.message)}\n#{CGI.escapeHTML(e.backtrace.join("\n"))}</pre>
HTML
else
[500, {"Content-Type" => "text/html"}, [File.read("public/500.html")]]
end
end
end
Request logging
class RequestLogger
def initialize(app)
@app = app
end
def call(env)
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
path = env["PATH_INFO"]
if path != "/health"
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
puts "#{status} #{env["REQUEST_METHOD"]} #{path} #{ms}ms"
end
[status, headers, body]
end
end
Composing the stack
Rack::Builder composes the middleware stack with the app:
module Framework
module Server
def self.app(db)
routes = build_routes(db) # returns Framework::App
Rack::Builder.new do
use RequestLogger
use ErrorHandler
use HTTPS if ENV["APP_ENV"] == "production"
use Rack::Session::Cookie,
key: "_session",
secret: ENV.fetch("SECRET_KEY_BASE"),
same_site: :lax,
secure: ENV["APP_ENV"] == "production"
use Rack::Static,
urls: ["/css", "/js", "/fonts", "/images"],
root: "public",
header_rules: [[:all, {"Cache-Control" => "public, max-age=31536000, immutable"}]]
use CSRF
use Flash
run routes
end.to_app
end
end
end
Routes without middleware can be exposed separately for testing:
def self.routes(db)
app = Framework::App.new
app.get "/health", HealthHandler::Check.new(db)
# ... more routes
app
end
This lets handler tests bypass auth and CSRF.