One recent contribution to the Rails codebase caught my attention. It concerns the distance_of_time_in_words
method. The fix is meant to prevent a possible Denial of Service while using this method.
The contribution was brought by Stazer. I found out about the PR in the newsletter This week in Rails.
The problem
The distance_of_time_in_words
method returns the approximate distance in time between two timeframes (can be Time
, Date
, or DateTime
objects or integers) and displays it in a nice, humanized format.
To be correct, the leap years between those two timeframes should be considered. It uses count
and a range to get the number of leap years.
[...]
leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) }
[...]
This is a blocking process. The calculation can take a long time if the distance between from_year
and to_year
is big enough.
Users might be able to trigger this DoS if they can set a timestamp which is then being passed to distance_of_time_in_words
.
I found it interesting how subtle this vulnerability is. The contributor encountered this problem in one of their personal projects and decided to open a PR to Rails.
The fix
This contribution safeguards against DoS. It calculates the leap years in constant time.
fyear = from_year - 1
(to_year / 4 - to_year / 100 + to_year / 400) - (fyear / 4 - fyear / 100 + fyear / 400)
I will present how you can test this fix locally.
Testing this fix
For this, I created a new, minimal Rails app:
rails new my_awesome_app --minimal
Then I wanted to override the distance_of_time_in_words
method. So I created this new file:
# config/initializer/actionview.rb
require 'action_view'
module ActionView::Helpers::DateHelper
alias __distance_of_time_in_words distance_of_time_in_words
private :__distance_of_time_in_words
def distance_of_time_in_words(_from_time, _to_time = 0, _options = {})
[...]
leap_years = if from_year > to_year
0
else
fyear = from_year - 1
(to_year / 4 - to_year / 100 + to_year / 400) - (fyear / 4 - fyear / 100 + fyear / 400)
end
[...]
end
def old_distance_of_time_in_words(_from_time, _to_time = 0, _options = {})
[...]
leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) }
[...]
end
end
I replaced the rest of the code in the method from the rails repo.
I’m now able to test the fix straight in the Rails console:
require "benchmark"
num_years = 100_000_000.years
Benchmark.bm do |x|
x.report("old") {
ApplicationController.helpers.old_distance_of_time_in_words(Time.now, Time.now + num_years)
}
x.report("new") {
ApplicationController.helpers.distance_of_time_in_words(Time.now, Time.now + num_years)
}
end
user system total real
old 6.095959 0.000000 6.095959 ( 6.096444)
new 0.000117 0.000000 0.000117 ( 0.000117)
[...]
Here we can see the big difference. The old code counted the leap years in a way that slowed things down, here, taking around 6 seconds to perform the count.
As the number of years between the two dates increases, the computing time grows much faster. When I tested it with a range of 1,000,000,000
years, it took 61 seconds. This has the potential to bring the application to a halt.
The updated code performs the calculation in constant time, regardless of the numbers of years.