I was adding filters to a list. Category, solved, a sort, the most ordinary screen in any application. The filter value now travels from the query string back into an HTML attribute so the form can remember it, and that made me write a test I had never written before in my life:
it "escapes a filter value on its way back into the form" do
get "/challenges?category=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E"
expect(last_response.body).not_to include("<script>alert(1)</script>")
end
It failed.
<input type="text" name="category" placeholder="category" value=""><script>alert(1)</script>">
The quote closed the attribute, the angle bracket closed the tag, and that script is a live element in the page.
This is the sixth post in a series where I keep twenty years of Rails muscle memory switched on inside a Hanami 3 application, and write down each place it stops working. This one is different from the other five, and I nearly wrote it wrong. It is worth reading for what the mistake turned out to be, not for the bug.
It was not my new feature
The first thing to check was whether I had just introduced this. So I looked at a value with a different provenance: a challenge name, read back out of the database rather than taken off the query string.
from the query string : NOT ESCAPED
from the database : NOT ESCAPED
Nothing this application rendered had ever been escaped. Not the filter I had added an hour earlier, not the list of challenges that has been on screen since the first article of this series. Every page, every value, since the first commit.
My suite was green. It had always been green. Forty-odd examples covering
actions, repos, operations and full HTTP requests, and not one of them had an
opinion about this, because I had never written one. Why would I. <%= %>
escapes. That is not a thing you check, it is a thing you inherit.
Hanami wants the opposite
The obvious next thought is that Hanami does not escape by default and I never read the manual. That thought is wrong, and I want to show how wrong, because it is the version of this story I was about to publish.
Its ERB parser says what it intends, in a comment above the line that decides:
# Expression tags: <%= "hello (auto-escaped)" %> or <%== "hello (not escaped)" %>
results.last << [:escape, indicator.size == 1, [:dynamic, code]]
Its engine installs Temple’s escaping filter. And compiling my own template by hand produces exactly what a person would hope for:
_buf << (::Temple::Utils.escape_html_safe(( filters[:category] )));
So the intent is documented, the machinery is correct, the compiled output calls the escaper, and the page still comes back injectable. At that point I stopped reading source and instrumented the actual render, which is the only move that ever works when the code says one thing and the output says another.
index.html.erb -> Tilt::ErubiTemplate
app.html.erb -> Tilt::ErubiTemplate
Not Hanami’s ERB template. Erubi, which does not escape unless you ask it to.
One line of extension matching
Tilt picks a template class by file extension, and my templates are called
index.html.erb. That name has two extensions in it, and Tilt matches the
longest one it knows. On the versions my lockfile had at that moment:
Mapping.split("index.html.erb") # => ["index", "html.erb"]
Mapping.lookup("erb") # => Hanami::View::ERB::Template
Mapping.lookup("html.erb") # => Tilt::ErubiTemplate
There it is. hanami-view unregisters erb and claims it for its own engine,
with a comment in the source explaining that it does this precisely so it
cannot be shadowed. It never claims html.erb, and until recently it had no
reason to. Compare the registration line across two Tilt releases:
# tilt 2.8.0
register_lazy :ErubiTemplate, 'tilt/erubi', 'erb', 'rhtml', 'erubi'
# tilt 2.9.0
register_lazy :ErubiTemplate, 'tilt/erubi', 'erb', 'rhtml', 'erubi', 'html.erb'
One extension, added to a patch release, and every template the Hanami generator writes goes to Erubi while the engine that escapes is never reached.
I had the fix by then, and it is one line in the view config. I was already drafting the post in my head. A framework whose generator produces templates that silently skip its own escaping engine is a good story.
It had been fixed for two weeks
Before writing that, I checked whether the gem I was blaming had a newer version. It did.
## [3.0.2] - 2026-08-21
### Fixed
- Ensure `Hanami::View::ERB::Engine` is still used for `.html.erb` templates
with Tilt 2.9. (@timriley in #284)
The diff is the line I had reasoned my way to, with a comment that describes what I had spent an hour measuring:
- mapping.unregister "erb", "rhtml", "haml", "slim"
+ # Tilt also matches the longest registered extension first, and it
+ # registers "html.erb" as an extension of its own ...
+ mapping.unregister "erb", "rhtml", "html.erb", "haml", "slim"
Two files differ between 3.0.1 and 3.0.2: that one, and the version number. The release exists for this and nothing else.
So the real sequence is not the one I was going to tell. Tilt shipped a patch
release that began registering html.erb. That silently disabled
auto-escaping in a framework downstream of it. Hanami fixed it four days
later. I generated this application on 17 August, my lockfile pinned
hanami-view 3.0.1 with tilt 2.9.0, and the fix landed on 21 August.
I did not find a bug. I found a bug I no longer had, in a four-day window my lockfile had been holding open ever since.
What would have told me
Nothing did, and this is the part I keep turning over.
Not my tests: green throughout, and green for a good reason, since escaping was the one behaviour I had never thought to assert. Not an advisory, because there is no CVE for this and no reason there would be. Not the changelog entry, which is filed under Fixed rather than Security, and which is a fair call by the people who wrote it: from inside the library, it is an engine selection bug. From inside my application, it is stored cross-site scripting on the screen that lists what I typed.
And not bundle outdated, because I never ran it. I ran the test suite, the
way I do twenty times a day, and the test suite is exactly the instrument that
cannot see this class of problem: my dependencies were doing something
different from last week and every assertion I owned still passed.
The one thing that did catch it was writing a test for something I assumed. Not a clever test. A test so obvious that writing it felt slightly silly, about a property so basic that checking it seemed like a waste of a line.
The uncomfortable half
Five posts into a series whose entire premise is reading the framework instead of assuming, published over an application whose every page was injectable, because escaping is the thing I inherited from Rails and never once looked at.
The application is a CTF tracker with no users but me, so nothing happened, and I get to write about it as an anecdote instead of an incident. That is luck, not process. The same lockfile discipline on something with a login form would have produced the same window, and the same silence.
What I changed is smaller than a lesson. bundle update moved from the pile
of chores to the same mental category as applying an operating system patch,
and when I add a form field that renders user input back into HTML I now write
the assertion that felt silly. Both of those are cheap. Neither would have
occurred to me a week ago.
The code is public if you want to read along: the filters and the sort, then taking the upstream fix rather than keeping my own workaround, which is its own small lesson about what a local override costs you later.
Comments, questions, or just a reaction?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.