ruby / web framework

I used a 500-line web framework built on Rack. The application served HTML with HTML templates, queried Postgres via DB, ran Rack middleware, and queued background work via job queues.

Why Rack

Rails adds many dependencies and abstractions. A small Rack framework contains only what the application needs. Stack traces point at application code. The small surface also fits in an AI context window.

Rack app with routing

A Rack app maps URL patterns to handler classes. The router stores each handler class and its constructor arguments at boot. It creates a new instance per request, so threads do not share state:

module Framework
  class App
    def initialize
      @routes = {"GET" => [], "POST" => []}
    end

    def get(path, klass, *args, **kwargs)
      @routes["GET"] << [compile(path), path, klass, args, kwargs]
    end

    def post(path, klass, *args, **kwargs)
      @routes["POST"] << [compile(path), path, klass, args, kwargs]
    end

    def redirect(from, to)
      @routes["GET"] << [compile(from), from, :redirect, [to], {}]
    end

    # Rack interface
    def call(env)
      req = Rack::Request.new(env)
      result = match(req.request_method, req.path_info)

      if result.nil?
        return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
      end

      klass, args, kwargs, params = result

      if klass == :redirect
        return [301, {"Location" => args[0]}, []]
      end

      env["router.params"] = params

      response = catch(:halt) do
        klass.new(*args, **kwargs).call(env)
      end

      response
    end

    private def match(method, path)
      @routes[method]&.each do |pattern, _, klass, args, kwargs|
        if (m = pattern.match(path))
          return [klass, args, kwargs, m.named_captures]
        end
      end
      nil
    end

    private def compile(path)
      pattern = path.gsub(/:([a-z_]+)/, '(?<\1>[^/]+)')
      Regexp.new("\\A#{pattern}\\z")
    end
  end
end

I register routes at boot:

app = Framework::App.new

app.get "/health", HealthHandler::Check, db
app.get "/companies/:id", CompaniesHandler::Show, db
app.post "/companies/create", CompaniesHandler::Create, db
app.redirect "/old-path", "/new-path"

run app

Path parameters such as :id become named captures in env["router.params"].

catch(:halt) lets a handler stop early with throw :halt, response. require_login uses it to redirect.

Handlers

Subclasses implement handle. The base class does setup and authentication:

module Framework
  class Handler
    attr_reader :db, :req, :params, :session

    def initialize(db)
      @db = db
    end

    def call(env)
      setup(env)
      require_login
      resp = handle
      resp = wrap_html(resp)
      resp
    end

    def setup(env)
      @env = env
      @req = Rack::Request.new(env)
      @params = @req.params.merge(env["router.params"] || {})
      @session = env["rack.session"] || {}
    end

    def handle
      raise NotImplementedError, "#{self.class} must implement #handle"
    end

    # Returns an HTML string. For partials and composition.
    def render(template, **locals)
      all_locals = locals.merge(default_locals)
      Template.render(template, all_locals, layout: nil)
    end

    # Returns a Rack response with layout. For full page renders.
    def page(template, title: "App", **locals)
      all_locals = locals.merge(default_locals).merge(title: title)
      html = Template.render(template, all_locals, layout: "layouts/application")
      [200, {"Content-Type" => "text/html"}, [html]]
    end

    def redirect(location)
      if !same_origin_or_internal_path?(location)
        return head(400)
      end
      [303, {"Location" => location}, []]
    end

    private def same_origin_or_internal_path?(location)
      if internal_only_path?(location)
        return true
      end
      target = URI.parse(location)
      base = URI.parse(base_url)
      target.scheme == base.scheme && target.host == base.host && target.port == base.port
    rescue URI::InvalidURIError
      false
    end

    private def internal_only_path?(path)
      if path.nil?
        return false
      end
      path.start_with?("/") && !["/", "\\", "@"].include?(path[1])
    end

    def head(status, headers = {})
      [status, headers, []]
    end

    private def require_login
      if current_user
        return true
      end

      @session[:return_to] = @req.fullpath
      throw :halt, redirect("/login")
    end

    # Auto-wrap string returns from handle into Rack responses.
    private def wrap_html(resp)
      if resp.is_a?(String)
        [200, {"Content-Type" => "text/html"}, [resp]]
      else
        resp
      end
    end

    private def default_locals
      {
        current_user: current_user,
        params: params,
        session: @session,
        flash: @env["app.flash"] || {}
      }
    end
  end
end

redirect checks the location to block open redirects.

Three base classes handle authentication:

A health check handler:

module HealthHandler
  class Check < Framework::PublicHandler
    def handle
      db.exec("SELECT 1")
      head 200
    end
  end
end

A page handler:

module CompaniesHandler
  class Show < Framework::Handler
    PageData = Data.define(:name, :status)

    def handle
      co = Companies::Find.new(db).call(id: params["id"])
      if co.nil?
        return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
      end

      data = PageData.new(
        name: co["name"],
        status: co["status"]
      )

      page "companies/show", title: data.name, data: data
    end
  end
end

Encrypted cookies

I encrypt the authentication token cookie with AES-256-GCM:

class EncryptedCookieJar
  def initialize(request_cookies, secret)
    @request_cookies = request_cookies
    @key = OpenSSL::Digest::SHA256.digest(secret)[0, 32]
  end

  def [](name)
    data = @request_cookies[name]
    if data.nil?
      return nil
    end

    decrypt(data)
  end

  private def encrypt(plaintext)
    cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
    cipher.key = @key
    iv = cipher.random_iv
    ciphertext = cipher.update(plaintext) + cipher.final
    tag = cipher.auth_tag
    Base64.urlsafe_encode64(iv + tag + ciphertext)
  end

  private def decrypt(data)
    raw = Base64.urlsafe_decode64(data)
    cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
    cipher.key = @key
    cipher.iv = raw[0, 12]
    cipher.auth_tag = raw[12, 16]
    cipher.update(raw[28..]) + cipher.final
  rescue
    nil
  end
end

Rack::Session::Cookie signs the session cookie. Only the authentication token uses the encrypted jar.

Middleware

Each middleware is a 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

The flash displays once because the middleware deletes it from the session.

Content Security Policy

See Content Security Policy for the header and the nonce middleware.

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 SafeStaticCache,
          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 returns the app without middleware, for handler tests:

def self.routes(db)
  app = Framework::App.new
  app.get "/health", HealthHandler::Check.new(db)
  # ... more routes
  app
end

Content Security Policy

The Content-Security-Policy header tells the browser which origins can load scripts, styles, and frames. A strict policy limits the damage from XSS. I start from default-deny and list what the application loads:

[
  "default-src 'self'",
  "base-uri 'none'",
  "object-src 'none'",
  "frame-ancestors 'none'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https://your-image-cdn.example",
  "connect-src 'self'"
].join("; ")

A new external host, such as an analytics script, requires an edit to this list.

Pitfalls

'unsafe-eval' enables eval, new Function, and setTimeout with a string body. The application does not need them.

https: in script-src accepts any HTTPS origin, which removes most of the value of CSP. I list specific hosts.

'unsafe-inline' in script-src allows inline <script> tags and event handlers, the most common XSS vector. I serve scripts from files or use a nonce.

Nonces for inline

When an inline script must pass server data to JavaScript, the CSP middleware generates a nonce per response:

class CSP
  def initialize(app)
    @app = app
  end

  def call(env)
    nonce = SecureRandom.base64(16)
    env["app.csp_nonce"] = nonce

    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' 'nonce-#{nonce}'",
        "style-src 'self' 'unsafe-inline'"
      ].join("; ")
    end

    [status, headers, body]
  end
end

The handler passes the nonce to templates as a default local:

%script{nonce: csp_nonce, type: "application/json", id: "boot"}
  != bootstrap_data_json

The browser executes the inline tag only if its nonce matches the response header.

Report-only first

Before I tighten a policy, I ship it as Content-Security-Policy-Report-Only for one release. The browser reports violations and blocks nothing. Then I fix the sources it reports and enforce.

CSP tests

A request test checks the header on every HTML response:

def test_csp_excludes_unsafe_eval
  resp = get("/")
  csp = resp.headers["Content-Security-Policy"].to_s
  ok { csp.include?("script-src 'self'") }
  ok { !csp.include?("unsafe-eval") }
  ok { !csp.match?(/script-src[^;]*\bhttps:\B/) }
end

A change that adds unsafe-eval fails this test.

Boot sequence

I require each gem explicitly. Bundler.require hides the dependency graph and slows boot.

Running with Puma

require "puma"
require_relative "lib/db"
require_relative "lib/framework/boot"
require_relative "lib/framework/server"

DB.configure do |c|
  c.pool_size = ENV.fetch("WEB_CONCURRENCY").to_i * ENV.fetch("WEB_THREADS").to_i
  c.reap = true
  c.statement_timeout_ms = 24_000
end

conf = Puma::Configuration.new do |c|
  c.app Framework::Server.app(DB.pool)
  c.environment ENV.fetch("APP_ENV")
  c.bind "tcp://0.0.0.0:#{ENV.fetch("PORT")}"
  c.threads ENV.fetch("WEB_THREADS").to_i, ENV.fetch("WEB_THREADS").to_i
  c.workers ENV.fetch("WEB_CONCURRENCY").to_i

  c.cluster do
    c.preload_app!

    c.before_worker_boot do
      DB.reset_pool! # fresh connections after fork
    end

    c.shutdown_debug on_force: true
  end
end

Puma::Launcher.new(conf).run

Timeout chain

A hung request holds a Puma thread and a database connection. Each layer sets a lower timeout than the layer outside it:

use Rack::Timeout, service_timeout: 25, term_on_timeout: 1

term_on_timeout requires Puma cluster mode (WEB_CONCURRENCY >= 2).

Testing handlers

Handler tests use Rack::Test against routes. See ruby / test framework.

class HandlerTest < Test
  include Rack::Test::Methods

  def app
    Framework::Server.routes(db)
  end

  def sign_in
    set_cookie("remember_token=test")
  end
end

class HealthTest < HandlerTest
  def test_health
    resp = get("/health")
    ok { resp.status == 200 }
  end
end

class CompaniesShowTest < HandlerTest
  def test_show
    sign_in
    co = insert_company(name: "Acme Inc")

    resp = get("/companies/#{co.id}")

    ok { resp.status == 200 }
    ok { resp.body.include?("Acme Inc") }
  end
end

Response modes

A handler declares its response modes:

# Ajax-only (forms, partials)
class CompaniesHandler::Edit < Framework::Handler
  modes :ajax

  def handle
    render "companies/edit", data: data
  end
end

# Dual-mode (full page + ajax partial)
class CompaniesHandler::Show < Framework::Handler
  modes :html, :ajax

  def handle
    if ajax?
      render "companies/ajax_show", data: data
    else
      page "companies/show", title: data.name, data: data
    end
  end
end

# HTML-only (default; no modes declaration needed)
class DashHandler::Home < Framework::Handler
  def handle
    page "dash/home", title: "Home", data: data
  end
end

The base class returns 400 for a request in an undeclared mode.

Ping test coverage in ruby / test framework checks that every mode has a test.

HTTP methods

The router accepts GET and POST only. HTML forms submit only those two.

← All articles