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:

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.

← All articles