I have written this line more times than I could count:

Challenge.includes(:event).where(category: params[:category]).order(params[:sort])

This series keeps twenty years of Rails reflexes switched on inside a Hanami 3 application, a small CTF challenge tracker, and records each place where they stop matching what the framework does. The post on escaping started on this screen, the list of challenges with its filters. This one comes from earlier the same day, when the sort did not sort.

The half that felt like home

The filters went in first, and there is nothing to report about them, which deserves a sentence of its own in a series about friction. In Hanami the query belongs to the repo rather than the action, but building it uses the same moves. where and order chain onto a relation exactly as they do on an Active Record scope:

def all_with_event(category: nil, solved: nil, sort: nil)
  listing = challenges.combine(:event)
  listing = listing.where(category: category) if category
  listing = listing.where(solved: solved) unless solved.nil?
  listing = listing.order(sort) if sort
  listing.to_a
end

That is the first version of the method. The category filter and the solved filter both worked on the first try. So did the sort, according to its spec:

it "orders by the requested column" do
  repo.create(attributes.merge(name: "Hard", difficulty: 5))
  repo.create(attributes.merge(name: "Easy", difficulty: 1))
  repo.create(attributes.merge(name: "Medium", difficulty: 3))

  listed = repo.all_with_event(sort: :difficulty)

  expect(listed.map(&:name)).to eq(%w[Easy Medium Hard])
end

Green. On the page, though, sorting by difficulty changed nothing: the list stayed in the order the rows had been inserted.

A spec that spoke Ruby

The argument in that spec is :difficulty. I wrote a Symbol because I was writing Ruby, and in Ruby a column name is a Symbol. A browser does not send Symbols. Here is what the action hands the repo, recorded by injecting a stand-in repo that prints its arguments, for two query strings against the finished action:

?solved=true&sort=difficulty
  repo receives {category: nil, solved: true, sort: "difficulty"}
  status 200
?sort=nope
  status 422

solved arrives as true, and sort arrives as a String. (The 422 for nope comes from the fix further down.) My spec had tested a value the application never receives. It proved the repo could sort by a Symbol, and said nothing about the only caller that mattered.

To check that I was not telling myself a tidy story after the fact, I put the first version of the repo back into an isolated copy of the app, alongside the two specs that came later, one passing a String to the repo and one going through HTTP. The original spec passes, and the other two fail:

  1) CtfTracker::Repos::ChallengeRepo#all_with_event, narrowed orders by a column name that arrived as a string
     Failure/Error: expect(listed.map(&:name)).to eq(%w[Easy Medium Hard])

       expected: ["Easy", "Medium", "Hard"]
            got: ["Hard", "Easy", "Medium"]

  2) GET /challenges orders the list by the column asked for in the query string
     Failure/Error: expect(last_response.body.index("Easy")).to be < last_response.body.index("Hard")

       expected: < 1043
            got:   1212

3 examples, 2 failures

(Output trimmed to the failures and the summary.)

A word, not a column

What the repo does with that String is the actual surprise. I ran the same relation through a handful of sort values on the app’s own database, three rows inserted as Hard, Easy, Medium:

:difficulty                          -> ORDER BY `difficulty`  ["Easy", "Medium", "Hard"]
"difficulty"                         -> ORDER BY 'difficulty'  ["Hard", "Easy", "Medium"]
"difficulty desc"                    -> ORDER BY 'difficulty desc'  ["Hard", "Easy", "Medium"]
:nope                                -> Sequel::DatabaseError
"nope"                               -> ORDER BY 'nope'  ["Hard", "Easy", "Medium"]
"difficulty; drop table challenges"  -> ORDER BY 'difficulty; drop table challenges'  ["Hard", "Easy", "Medium"]
"1=1"                                -> ORDER BY '1=1'  ["Hard", "Easy", "Medium"]
table still there: 3 rows

Look at the quotes. A Symbol becomes an identifier in backticks, a column. A String becomes a string literal in single quotes, a value. So ORDER BY 'difficulty' sorts every row by the same constant word, every row ties, and the database returns them in whatever order it likes. Nothing raises, and the only trace is a SQL line in the debug log. To anyone using the page, the sort link is simply broken.

This is not a quirk of ROM, which hands order to Sequel underneath. Sequel’s security guide says it plainly: it “treats ruby symbols as SQL identifiers, and ruby strings as SQL strings”, and since Sequel 5 it no longer turns plain strings into raw SQL in ordinary code.

The last lines of that output matter just as much. The String with drop table in it came back as a quoted value, and the table was still there afterwards. There is no injection through order here, for exactly the reason there is no sorting: Sequel never reads a String as SQL. The safety and the silence come from one design decision.

The same line in Rails

My belief about Rails was the reverse: that order("difficulty") works there, because a String is a SQL fragment, and that this is exactly why passing params[:sort] to it is dangerous. Memory is not evidence, so I ran the same values against Active Record 8.1.4.1 on SQLite:

:difficulty                          -> ORDER BY "challenges"."difficulty" ASC  ["Easy", "Medium", "Hard"]
"difficulty"                         -> ORDER BY difficulty  ["Easy", "Medium", "Hard"]
"difficulty desc"                    -> ORDER BY difficulty desc  ["Hard", "Medium", "Easy"]
:nope                                -> ORDER BY "nope" ASC  ["Hard", "Easy", "Medium"]
"nope"                               -> ActiveRecord::StatementInvalid
"difficulty; drop table challenges"  -> ActiveRecord::UnknownAttributeReference
"1=1"                                -> ActiveRecord::UnknownAttributeReference
"(select 1)"                         -> ActiveRecord::UnknownAttributeReference
"lower(name)"                        -> ORDER BY lower(name)  ["Easy", "Hard", "Medium"]
table still there: 3 rows

Half of my memory held. The String goes into the SQL as written, so the Rails version of my line sorts correctly from a query string. The other half was out of date. Rails no longer lets any String through: the 5.2 changelog deprecated raw SQL in query methods and announced UnknownAttributeReference for 6.0. What decides is a regular expression in the SQLite adapter, column_name_with_order_matcher. It accepts a column name, optionally qualified by its table, or a function call wrapping one column or nothing, each followed by an optional collation and an optional ASC or DESC, and a comma-separated list of those. Anything shaped differently raises, unless you wrap it in Arel.sql to say you trust it.

So Rails guesses what a String is from its shape, and Sequel decides from its class. Both answers are defensible. Rails’ guess is generous: lower(name) passes, which is a function call chosen by whoever edited the URL, harmless here and still not something I meant to offer. And "nope" is a 500 in Rails and a silent no-op in Sequel, which means the naive line is wrong in both frameworks, for different reasons.

One row in that table needs a footnote. In Rails, :nope returned rows instead of failing. That is SQLite rather than Rails: SQLite reads a double-quoted name that matches no column as a string literal, a compatibility choice its own documentation calls a misfeature. Sequel quotes identifiers with backticks on SQLite, so the same mistake is an error there. Both runs used SQLite, and I have not checked this row on another database.

Solved got a type, sort got a list

The solved filter has the same shape as the sort: a query string delivers "true", and the repo wants true. It worked on the first try because the action’s params schema has a type for it, and coerces the value at the edge. The output above shows it: solved: true, a real boolean, by the time the repo sees it.

There is no type for “a column of this table”. filled(:string) accepts "difficulty" and "nope" with the same enthusiasm. A column name needs a list of allowed values, which is a different kind of statement from a type. So the repo now names its sortable columns and converts the String itself, but only into one of them:

# Sequel orders by a Symbol as a column and by a String as a literal
# value, so a sort that arrives from a query string orders by nothing at
# all, in silence. Naming the columns we accept settles that and keeps an
# unknown one from reaching the database as an identifier.
SORTABLE = %i[name category difficulty points created_at].freeze
def sortable(sort)
  column = sort.to_sym
  return column if SORTABLE.include?(column)

  raise ArgumentError, "cannot sort challenges by #{sort.inspect}"
end

The list matters as much as the conversion. A bare to_sym fixes the sort and opens the other door: :nope reaches SQLite as an identifier and comes back as a Sequel::DatabaseError, a 500 for someone who edited a URL. The ArgumentError stops it before the database, but it is still a programming error, not an answer for a visitor. So the action’s params schema offers the same list:

params do
  optional(:category).filled(:string)
  optional(:solved).filled(:bool)
  optional(:sort).filled(included_in?: Repos::ChallengeRepo::SORTABLE.map(&:to_s))
end

An edited sort is now a 422, and the repo’s refusal is a guard that should never fire in practice. The template builds its <select> from the same constant, through a sorts exposure, rather than keeping a third copy of the list.

In Rails I would have put that list in a constant on the model, and let the controller and the view read it from there. Hanami has no model to hang it on, and the repo turns out to be the same answer under another name: it owns the query, so it owns the list. Three layers need to agree about one list, and the arrangement that stays correct is two of them asking the third.

What I left undone

The form does not show the current filters after a submit, so filtering by pwn and then picking a sort drops the category. That is an ordinary omission, nothing to do with Hanami, and I would rather say so than hide it behind a screenshot.

The spec I started with is still in the suite, next to the two that would have caught this: one passing the String the repo really receives, one going through HTTP like a browser. The lesson I took is narrower than “test more”. A spec is written in Ruby and an application receives text. By writing :difficulty in that spec, I had done the conversion the application never did, and then asked the application to agree with me.

The filters, the sort and the specs are one commit in the companion app, tagged 05-symbol-or-string.

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.