ruby / html templates

My web framework rendered HTML with Haml templates, layouts, and partials. Over a few years those templates were restricted to a subset small enough that a custom renderer replaced the haml gem, and then small enough to port to Go.

The language that came out of it is specified in go / html templates. This article describes what the restricting took, the part that does not port.

Typed view data with Data.define

Passing raw hashes to templates is error-prone. Instead, define Data.define structs in the handler that specify the exact contract with the template:

module CompaniesHandler
  class Index < Framework::Handler
    PageData = Data.define(:summary, :rows)
    Row = Data.define(:name, :status, :edit_url)

    def handle
      companies = Companies::All.new(db).call
      count = companies.count { |co| co["active"] }

      data = PageData.new(
        summary: "#{count} active",
        rows: companies.map { |co|
          Row.new(
            name: co["name"].to_s,
            status: co["status"].to_s,
            edit_url: "/companies/edit?id=#{co["id"]}"
          )
        }
      )

      render "companies/index", data: data
    end
  end
end

The template receives a data struct and renders HTML. It accesses data.field and row.field but does not call formatters, access hash keys, or transform data:

= content_for :title, "Companies"

%h1
  = data.summary

%table
  - data.rows.each do |row|
    %tr
      %td
        = row.name
      %td
        = row.status
      %td
        %a{href: row.edit_url}
          Edit

The struct enforces the contract:

Row = Data.define(:name, :status, :edit_url)

Row.new(name: "Acme", status: "Active")
# ArgumentError: missing keyword: :edit_url

With hashes, the same bug often renders as blank UI:

row = { "name" => "Acme", "status" => "Active" }
row["stauts"] # nil (typo)

Handlers also pre-compute booleans and strings, so templates don't contain nil-safety logic:

-# before
- if person["headline"].to_s.strip != ""
  = person["headline"]

-# after
- if person.headline
  = person.headline

Handlers keep formatting and branching, which I can unit test and debug more easily.

Restrict before replacing

The haml gem evaluates arbitrary Ruby at render time. Templates can call methods, access constants, assign variables. A subset that does none of that needs only a parser with no node type for any of it. Such a parser takes eval out of the rendering path and drops a dependency.

The renderer itself was small. Two files, around 1,200 lines: a parser and renderer, and a constrained expression evaluator. The templates were the hard part. I could not write the renderer until every one of about 360 templates conformed, because a renderer that rejects a construct is worthless if one page in production still uses that construct. So the order was:

  1. Move method calls, hash access, and formatting into handlers, behind Data.define structs, a screen at a time.
  2. Hold the line with a linter in CI.
  3. Write the renderer against the grammar that was left, and delete the linter.

Step 2 is where the design showed up. The linter was regexes, and I could not fix its gaps. A regex cannot parse a nested expression, and every violation someone invented needed a new rule. The linter caught only what it had already seen.

The renderer replaced the linter because it parses. It parses every template at boot, so a construct outside the subset crashes the process before it serves a request. A template that parses is in the subset.

Freezing the grammar that way is also what made the Go port a port. I rewrote the engine in another language with different value semantics, and the templates mostly carried over. By then they held field access and control flow and nothing else.

Escaping at the source

= HTML-escapes by default and != is a parse error, so the template layer is safe by construction. The only raw HTML comes from a few code paths that build markup in Ruby and hand back a trusted SafeString:

Each of those is a place to escape, not a place to trust input.

A formatter that builds HTML escapes every dynamic piece, even when it looks safe today:

require "cgi"

module Fmt
  module Lists
    def self.list(name, url)
      esc_name = CGI.escapeHTML(name.to_s)
      esc_url  = CGI.escapeHTML(url.to_s)
      %(<a href="#{esc_url}">#{esc_name}</a>)
    end
  end
end

A formatter that returns HTML and forgets to escape one parameter is one of the most common XSS sources in a server-rendered app.

Postgres' ts_headline returns a snippet with <b>...</b> markers around matching terms. The terms are user input. Escape the entire snippet, then reintroduce only the markers the function adds:

require "cgi"

def safe_headline(snippet)
  CGI.escapeHTML(snippet.to_s)
    .gsub("&lt;b&gt;",  "<b>")
    .gsub("&lt;/b&gt;", "</b>")
end

Wrap the result as a SafeString so the template renders it without re-escaping.

A flash that interpolates a user-controlled name renders in the next response. Escape on the way in:

flash_next(:notice, "Merged into #{CGI.escapeHTML(target.name)}")

If the layout treats every flash value as HTML, the producer is the right place to escape. The reader cannot tell which strings are safe.

← All articles