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 is what the restricting took, which is 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

This gives hard guarantees, not just style guidance.

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

This keeps formatting and branching in handlers, which is easier to unit test and debug.

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 can be rendered by a parser with no node type for any of it, which takes eval out of the rendering path and drops a dependency.

The renderer was not the hard part. Two files, around 1,200 lines: a parser and renderer, and a constrained expression evaluator. The hard part was that it could not be written until every one of about 360 templates already conformed, because a renderer that rejects a construct is worthless if one page in production still uses it. 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 it was incomplete in a way that could not be fixed: a regex cannot parse a nested expression, and every violation someone invented needed a new rule. It caught what it had already seen.

The renderer replaced it by being a parser. Templates are parsed at boot, so a construct outside the subset crashes the process before it serves a request. If it parses, it's in the subset. If it's not in the subset, it doesn't parse.

Freezing the grammar that way is also what made the Go port a port. The engine was rewritten in another language with different value semantics, and the templates mostly carried over, because by then they were 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 every flash value is treated as HTML by the layout, the producer is the right place to escape. The reader cannot tell which strings are safe.

← All articles