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:
- Move method calls, hash access, and formatting into handlers, behind Data.define structs, a screen at a time.
- Hold the line with a linter in CI.
- 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:
- formatters that build link or badge HTML
- search snippets returned by Postgres
ts_headline - flash messages built by interpolating user-supplied names
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("<b>", "<b>")
.gsub("</b>", "</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.