ruby / params
User-supplied strings flowing into SQL ORDER BY, into a path
segment, or into a redirect target need a fixed allowlist.
Anything not in the list is rejected or replaced with a safe
default.
Sort and direction
class Companies::Filter
SORT_COLUMNS = %w(name created_at score).freeze
SORT_DIRS = %w(asc desc).freeze
def initialize(db)
@db = db
end
def call(sort:, dir:)
sort = SORT_COLUMNS.include?(sort) ? sort : "name"
dir = SORT_DIRS.include?(dir) ? dir : "asc"
@db.exec(<<~SQL)
SELECT
id,
name
FROM
companies
ORDER BY
#{sort} #{dir}
SQL
end
end
The pg driver cannot bind identifiers, so ORDER BY $1 $2
will not work. Interpolation is unavoidable. The allowlist
makes it safe.
Path segments
Tenant or pod identifiers in URLs:
PODS = %w(us eu ap).freeze
pod = params["pod"].to_s
if !PODS.include?(pod)
return head 404
end
A 404 on a missing pod is preferable to a 500 from a downstream lookup that failed in an undefined way.
External URLs
When an external URL is acceptable input (image fetch, redirect target, webhook callback), parse it and check the components:
def safe_url(input, base_host:)
uri = URI.parse(input.to_s.strip)
if uri.scheme != "https"
return nil
end
if uri.host.nil?
return nil
end
if uri.host != base_host
return nil
end
uri
rescue URI::InvalidURIError
nil
end
Substring checks on the raw string are easy to fool.
https://www.example.com.attacker.tld/x contains the literal
example.com even though the host is attacker.tld. Parse
first, compare the parts. The
web framework redirect helper applies
the same rule before issuing a 303.
Why constants
Keeping the allowlist as a frozen constant near the SQL gives three properties:
- the reader sees every legal value at a glance
- a new value requires editing the file, not the database
- a test can iterate the constant and assert each value renders
def test_every_sort_column_renders
Companies::Filter::SORT_COLUMNS.each do |col|
Companies::Filter::SORT_DIRS.each do |dir|
ok { Companies::Filter.new(db).call(sort: col, dir: dir).is_a?(Array) }
end
end
end
A new column added without updating the test fails CI.
See ruby / authz for the ownership checks that complement these input checks.