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.

← All articles