Rendering HTML in Rails
render html: "<b>Ada</b>" puts the literal text <b>Ada</b> on the page, angle brackets and all.
That is the first thing anybody meets when they go looking for how to render HTML from a Rails
controller, and the usual next move is .html_safe, which works and which is also how a cross site
scripting hole gets opened by somebody who was only trying to bold a name.
The four options that answer a request with HTML are not variations on one mechanism. They become
four different objects inside Action View, and those objects disagree about escaping, about
Content-Type and about layouts. Everything below was run on a generated Rails 8.1.3.1 app with
Ruby 4.0.5 on an Apple M2 Max, against probe controllers whose source is in this page. The 27
tests that hold the claims are at the end.
What each option turns into
ActionView::TemplateRenderer#determine_template, at
actionview-8.1.3.1/lib/action_view/renderer/template_renderer.rb:16, is a single if chain that
picks a class by which key is present:
if options.key?(:body)
Template::Text.new(options[:body])
elsif options.key?(:plain)
Template::Text.new(options[:plain])
elsif options.key?(:html)
Template::HTML.new(options[:html], formats.first)
with :inline at line 35 building a Template::Inline, and :template at line 45 going through
the lookup context to find a real file. That is the whole dispatch. Two consequences fall out of
it immediately.
The first is that render body: and render plain: produce the identical object. In a Rails
8.1.3.1 app both answer text/plain; charset=utf-8, and the Rails documentation comment for the two
options, at action_controller/metal/rendering.rb:83 and :89, is the same sentence twice. If you
are choosing between them for a reason other than reading intent, there is no reason.
The second is that ActionView::Template::HTML#initialize is @string = string.to_s (html.rb:10).
It does not check the type. These three requests went through the same controller:
def html_integer
render html: 42
end
def html_nil
render html: nil
end
def html_with_hash
render html: { a: 1 }
end
and answered "42", "" and "{a: 1}", all three with text/html; charset=utf-8. So a
render html: @post that was meant to be render html: @post.body does not raise anywhere. It
answers 200 with whatever to_s gives.
The escape, and the one thing that stops it
ActionController::Rendering#_normalize_options escapes in one line:
if options[:html]
options[:html] = ERB::Util.html_escape(options[:html])
end
rendering.rb:236 to :238. ERB::Util.html_escape in Rails returns its argument unchanged when
the argument is already html_safe?, which is the only exit. So this controller action:
def escape_of_quotes
render html: %(a "b" 'c' & <d>)
end
answered, byte for byte:
a "b" 'c' & <d>
Five characters, the CGI.escapeHTML set, single quote included. There is a second escape further
down in Template::HTML#to_str, which is ERB::Util.h(@string) at html.rb:21, and it is not a
double escape: the value arriving there is already an ActiveSupport::SafeBuffer and passes
through.
Three things produce a value that survives. A String you called .html_safe on, which asserts
nothing and is where the trouble starts. The tag builder, helpers.tag.b("Ada"), which returns a
SafeBuffer and escapes the content while keeping the markup. And anything render_to_string
gave you: render_to_string(partial: "probes3/row", locals: { n: 3 }) came back as a SafeBuffer,
so feeding it straight into render html: produced <li>3</li> and not <li>3</li>.
Note the asymmetry in that last one. render_to_string(html: "<b>Ada</b>") returns
"<b>Ada</b>", an escaped string, and that escaped string is itself html_safe?. The
SafeBuffer wrapper says "this has been escaped", never "this was safe to begin with".
The injection
This is the shape of it, and it is the reason the page exists. A controller that builds a fragment
with interpolation and calls .html_safe on the result:
# The wrong one: html_safe applied to a string that contains user input.
def unsafe_interpolation
render html: "<p>Hello #{params[:q]}</p>".html_safe
end
A GET /x/unsafe_interpolation?q=<img src=x onerror=alert(1)> answered:
<p>Hello <img src=x onerror=alert(1)></p>
.html_safe is not a sanitiser and does not look at the string. It sets a flag that tells Action
View to skip the escape, and the flag applies to the whole buffer including the half of it that came
from a query parameter.
Two fixes, and they are not interchangeable. If you wrote the markup and only the content is untrusted, build the markup and let Rails escape the content:
def tag_builder
render html: helpers.tag.p("Hello #{params[:q]}")
end
which answered <p>Hello <img src=x onerror=alert(1)></p>. If the markup itself came from
somewhere you do not control, a CMS field or a Markdown renderer, you have to sanitise:
def sanitized
render html: helpers.sanitize("<p>Hello #{params[:q]}</p>")
end
which answered <p>Hello <img src="x"></p>. The img survived because img is on the safe list;
onerror did not because it is not on the attribute list.
That safe list is 43 tags on this app with rails-html-sanitizer 1.7.1 and no Action Text installed:
a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6
hr i img ins kbd li mark ol p pre samp small span strong sub sup time tt ul var
script is not on it, and here is the part that surprises people the first time: the tag goes, the
text inside it stays. sanitize("<b onclick='x'>Ada</b><script>alert(1)</script>") returned
"<b>Ada</b>alert(1)". Harmless as markup, and visible on the page as the literal string
alert(1), which looks like a rendering bug to whoever reports it.
Layouts
The rule is one method, ActionView::Layouts#_include_layout? at layouts.rb:430:
def _include_layout?(options)
!options.keys.intersect?([:body, :plain, :html, :inline, :partial]) || options.key?(:layout)
end
So render html: and render inline: and render partial: have no layout, render template: and
the implicit render do, and passing :layout at all flips the first group back on. render html:
"<b>Ada</b>".html_safe, layout: true came back at 629 bytes with the full <!DOCTYPE html> around
it.
The same call with plain: does not:
def plain_with_layout
render plain: "hello", layout: true
end
ArgumentError: There was no default layout for ProbesController in #<ActionView::PathSet:0x...
raised from layouts.rb:423. The cause is two files away: Template::Text#format returns :text
(text.rb:28), TemplateRenderer#render calls prepend_formats(template.format), and the layout
lookup then wants layouts/application.text.erb, which no generated app has.
The version of that mistake which costs an afternoon is the quiet one. render plain: "hello",
layout: "application" raises nothing, logs nothing, and answers hello with no layout. Naming the
layout gets you silence; asking for the default gets you an exception.
Content-Type, and the option that is ignored
content_type: works on plain: and body:. It does not work on html::
render html: "<b>Ada</b>".html_safe, content_type: "application/xhtml+xml"
# => text/html; charset=utf-8
AbstractController::Rendering#render branches on the option before the response is built, at
abstract_controller/rendering.rb:29:
if options[:html]
_set_html_content_type
else
_set_rendered_content_type rendered_format
end
_set_html_content_type is self.content_type = :html, unconditional. The same unconditionality is
why GET /p/html_escaped.json still answers text/html; charset=utf-8 and a 200, where
render template: "probes/card" on the same .json request raises ActionView::MissingTemplate.
render html: opts out of format negotiation entirely. That is convenient for an error fragment and
wrong for anything a client is parsing.
What render inline: costs
ActionView::Template::Inline is built fresh inside determine_template on every call, so
@compiled is false every time and Template#compile! (template.rb:424) defines a new method
on the view class for each render. inline.rb:18 attaches an ObjectSpace.define_finalizer to take
it back off again, with a comment saying the double proc is needed "otherwise templates leak in
development".
It works, and you can watch it work:
SRC = File.read(Rails.root.join("app/views/bench/row.html.erb"))
ctx = BenchController.new.view_context
mod = ctx.compiled_method_container
c = -> { mod.instance_methods(false).grep(/^_/).size }
puts "before: #{c.call}"
1000.times { ctx.render(inline: SRC) }
puts "after 1000 inline: #{c.call}"
3.times { GC.start }
puts "after 3x GC.start: #{c.call}"
1000.times { ctx.render(template: "bench/row") }
puts "after 1000 tpl: #{c.call}"
before: 0
after 1000 inline: 320
after 3x GC.start: 0
after 1000 tpl: 1
The middle number is the only one that moves. Five runs of that script gave 237, 301, 320, 320 and 320, because it reports how many finalizers the garbage collector had not got to yet. What does not move is that it is neither 1 nor 1000: the methods are real, and they are cleaned up on somebody else's schedule.
That single method left by the file template is
_app_views_bench_row_html_erb__1750863855296182179_13896. It is compiled once and called 999 more
times. The inline version compiled 1000 times and handed the garbage collector 1000 finalizers to
run.
The cost, in process, RAILS_ENV=production, 2000 renders, best of 5 runs after 3 warmups, on an
Apple M2 Max with 12 cores:
template 1 line : 0.0155 ms/render
inline 1 line : 0.0386 ms/render
render html: : 0.0108 ms/render
template 40 lines : 0.0327 ms/render
inline 40 lines : 0.3514 ms/render
source bytes: small=65 big=2600
The file template roughly doubles between 65 bytes and 2600 bytes of source. The inline one goes up by a factor of nine, because the compile is proportional to the source and the compile happens every time.
The measurement that did not work
The first attempt at that was ApacheBench against a Puma running the same three actions in
production mode, single worker, single thread, ab -n 3000 -c 1. It said 2119 requests per second
for the file template and 2067 for inline, a 2.5 percent difference. Three interleaved rounds at
-n 4000 then put inline ahead in one of them, 1804 against 1759. 0.023 ms of extra compile inside
a 0.47 ms request is not resolvable on a laptop whose load average was 14.45 at the time.
The 2600 byte version is outside the noise, and that one does show up over HTTP. Five interleaved
rounds of ab -n 4000 -c 1:
big_template 1971.14
big_inline 1105.01
big_template 1925.22
big_inline 1050.88
big_template 1819.64
big_inline 897.72
big_template 1762.57
big_inline 1069.52
big_template 1748.02
big_inline 1115.52
So the honest version of the advice is not "inline is slow". It is that inline pays its compile per request, the compile scales with the source, and the point where that becomes visible to a user is somewhere above a couple of kilobytes of ERB on a hot path. Below that, use it for a two-line fragment in a spike and do not go back to change it.
Rendering HTML with no request
ApplicationController.render renders outside a request, which is what you want when a job builds
an email body or a script dumps a page. The escaping rules are identical:
ApplicationController.render(html: "<b>Ada</b>") # => "<b>Ada</b>"
ApplicationController.render(html: "<b>Ada</b>".html_safe) # => "<b>Ada</b>"
ApplicationController.render(inline: "<b><%= 1 + 1 %></b>") # => "<b>2</b>"
The URLs are not. ActionController::Renderer::DEFAULTS is {method: "get", input: ""}
(renderer.rb:30) and carries no host, so the Rack env falls back to
rack-3.2.7/lib/rack/mock_request.rb:105, which is (uri.host || "example.org"). Running in
RAILS_ENV=production:
ApplicationController.render(inline: "<%= root_url %>") # => "http://example.org/"
No warning, no log line. Every absolute link in that email points at example.org. Two ways out, and the second is the one to use per call site:
Rails.application.routes.default_url_options[:host] = "example.com"
ApplicationController.render(inline: "<%= root_url %>")
# => "http://example.com/"
r = ApplicationController.renderer.new(https: true, http_host: "launchkit.codes")
r.render(inline: "<%= root_url %>")
# => "https://launchkit.codes/"
Two smaller things
raw is a view helper and is not on the controller. render html: raw("<b>Ada</b>") inside an
action raises NoMethodError: undefined method 'raw' for an instance of ProbesController. From a
controller the reach-across is helpers.raw, or view_context.raw; inside a template <%== x %>
is the same thing as <%= raw x %>, and both returned "<b>x</b>" where <%= %> returned
"<b>x</b>".
Calling render twice in one action raises AbstractController::DoubleRenderError from
rendering.rb:172, which is a guard on response_body being set, not on the action having
returned. A render in a branch that falls through to a second render is the usual way to meet
it.
The test
Every behaviour above except the timings is asserted in one Minitest integration file against the probe controllers, on a generated Rails 8.1.3.1 app:
$ bin/rails test test/integration/render_html_test.rb
Running 27 tests in a single process (parallelization threshold is 50)
Run options: --seed 1201
# Running:
...........................
Finished in 0.147884s, 182.5755 runs/s, 371.9131 assertions/s.
27 runs, 55 assertions, 0 failures, 0 errors, 0 skips
What this post does not cover
Partials. render partial:, collections, strict locals and the partial lookup rules are their own
subject, and nothing above touches them beyond the one render_to_string(partial:) call used to
show that a SafeBuffer survives a second trip through render html:.
Also absent: render json: and the rest of the ActionController::Renderers registry, including
ActionController::Renderers.add for a format of your own; Turbo Streams, which are HTML over the
wire but carry their own MIME type, their own layout rules and their own reasons a fragment fails to
appear; ViewComponent and Phlex, which replace the template with a Ruby object and reach render
through render renderable:; and the Rails 8.2 ERB engine change, since everything measured here
compiled through Erubi and the inline numbers in particular will not transfer to Herb, which parses
and compiles differently.
Comments
No comments yet. Be the first.