-
-
Notifications
You must be signed in to change notification settings - Fork 467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prevent user enumeration by timing attacks #909
Conversation
Closes #636 Before this commit, we were skipping any BCrypt (or other password strategy) checks when we couldn't find the user by email. This opened up the possibility for a bad actor to detect whether an account existed for a given email address based on the timing of the response. This commit mitigates the problem by creating a throw away user and setting a dummy password on it, triggering BCrypt to create an encrypted password. The added tests were consistently failing before this change because authentication for accounts that did NOT exists was taking 1-2ms longer that for accounts that did exist (with the cost set to ::BCrypt::Engine::MIN_COST in tests). The timings are now very close. The `be_within` delta could almost have been 0.0001, but I stuck with 1ms to avoid any flakiness in the test suite.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL!
I have just one question regarding testing but this looks great! 👍
@@ -47,6 +47,35 @@ | |||
expect(User.authenticate(user.email, "bad_password")).to be_nil | |||
end | |||
|
|||
it "takes the same amount of time to authenticate regardless of whether user exists" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is running just one comparison enough to feel confident here? Or should we run it multiple times and make sure all of them remain within the expected delta?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question. How many times do you think would be reasonable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I honestly don't know if it is even necessary. I was just thinking of the fuzz testing I've seen done in Elm apps.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I run each 100 times I can bump the be_within
delta from 0.001 to 0.0001 (* 100). That is nice, but it also adds 0.4 seconds to our test suite. I am torn.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I am going to leave it alone for now to keep the test suite as fast as possible. These tests in their current form are enough to catch a regression to the old behavior. We can always revisit this if we find that the 1ms delta is not sufficient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great! I had a question about the specifics around the timings.
DUMMY_PASSWORD = "*" | ||
|
||
def prevent_timing_attack | ||
new(password: DUMMY_PASSWORD) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding is that this will call password=
, which will result in the password strategy running. For BCrypt that is
clearance/lib/clearance/password_strategies/bcrypt.rb
Lines 22 to 31 in 1dd09b0
def password=(new_password) | |
@password = new_password | |
if new_password.present? | |
self.encrypted_password = ::BCrypt::Password.create( | |
new_password, | |
cost: configured_bcrypt_cost, | |
) | |
end | |
end |
In the case where a user was found, we'd end up calling authenticated?
, which would again call the password strategy
clearance/lib/clearance/password_strategies/bcrypt.rb
Lines 16 to 20 in 1dd09b0
def authenticated?(password) | |
if encrypted_password.present? | |
::BCrypt::Password.new(encrypted_password) == password | |
end | |
end |
We want these two code paths to take the same amount of time to avoid leaking via timing attacks. Do BCrypt::Password#==
and BCrypt::Password.create
use the same process under the hood that causes them to take the same amount of time? Will this be true for all password strategies?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For BCrypt the timings seem to be the same
require 'benchmark'
require 'benchmark/ips'
require "bcrypt"
encrypted_password = BCrypt::Password.create("password")
Benchmark.ips do |bm|
bm.report("compare") { BCrypt::Password.new(encrypted_password) == "password" }
bm.report("create") { BCrypt::Password.create("password") }
bm.compare!
end
Warming up --------------------------------------
compare 1.000 i/100ms
create 1.000 i/100ms
Calculating -------------------------------------
compare 4.889 (± 0.0%) i/s - 25.000 in 5.114814s
create 4.870 (± 0.0%) i/s - 25.000 in 5.134016s
Comparison:
compare: 4.9 i/s
create: 4.9 i/s - 1.00x (± 0.00) slower
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For argon2 as well:
require 'benchmark'
require 'benchmark/ips'
require "argon2"
encrypted_password = Argon2::Password.new.create("password")
Benchmark.ips do |bm|
bm.report("compare") { Argon2::Password.verify_password("password", encrypted_password) }
bm.report("create") { Argon2::Password.new.create("password") }
bm.compare!
end
compare 1.000 i/100ms
create 1.000 i/100ms
Calculating -------------------------------------
compare 8.335 (± 0.0%) i/s - 42.000 in 5.044675s
create 8.314 (± 0.0%) i/s - 42.000 in 5.057424s
Comparison:
compare: 8.3 i/s
create: 8.3 i/s - 1.00x (± 0.00) slower
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome, thanks for the benchmark! I think this is the best we can do until something changes. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went with this approach because it was the only way I could think of fix this for any password strategy. Another way to fix would be to build a DUMMY_ENCRYPTED_PASSWORD and check against that, but the password strategies don't currently have an API for doing that so that would only work for our officially supported password strategies by modifying the API a bit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think what you have here is excellent. If we find that some supported strategy in the future has different timings for creating and equality checks we can revisit this.
Sounds good. I'd love to pair on that with you if we can find some overlapping time. |
I would also join if the time works out! |
* see thoughtbot#916 * similar to thoughtbot#909 * also see GHSA-hrqr-hxpp-chr3 for an example of the type of attack that could be possible with an injectable cookie value * Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html since Rails 3 (??) which prevents tampering * using a signed cookie instead of a plain one, means the attacker cannot forge the cookie value, and therefore cannot perform timing attacks to find a valid token * another added value is that tampering with the cookie will not even hit the database * added a configuration parameter `signed_cookie` so this is optional and defaults to false for backwards compatibility (however, for better security, it might be better to issue a breaking change and default to true) * updated specs
* see thoughtbot#916 * similar to thoughtbot#909 * also see GHSA-hrqr-hxpp-chr3 for an example of the type of attack that could be possible with an injectable cookie value * Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html since Rails 3 (??) which prevents tampering * using a signed cookie instead of a plain one, means the attacker cannot forge the cookie value, and therefore cannot perform timing attacks to find a valid token * another added value is that tampering with the cookie will not even hit the database * added a configuration parameter `signed_cookie` so this is optional and defaults to false for backwards compatibility (however, for better security, it might be better to issue a breaking change and default to true) * changed the add_cookies_to_headers method to use ActionDispatch / Rails' cookie-handling code to set the cookie * updated specs
* see thoughtbot#916 * similar to thoughtbot#909 * also see GHSA-hrqr-hxpp-chr3 for an example of the type of attack that could be possible with an injectable cookie value * Rails provides signed cookies https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html since Rails 3 (??) which prevents tampering * using a signed cookie instead of a plain one, means the attacker cannot forge the cookie value, and therefore cannot perform timing attacks to find a valid token * another added value is that tampering with the cookie will not even hit the database * added a configuration parameter `signed_cookie` so this is optional and defaults to false for backwards compatibility (however, for better security, it might be better to issue a breaking change and default to true) * changed the add_cookies_to_headers method to use ActionDispatch / Rails' cookie-handling code to set the cookie * updated specs
This commit introduces signed cookies into Clearance using the signed cookies functionality provided by [ActionDispatch](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). By using a signed cookie an attacker cannot forge the cookie value, and therefore cannot perform timing attacks to find a valid token. See [this Rack security advisory](GHSA-hrqr-hxpp-chr3) for an example of the type of attack that could be possible with an injectable cookie value. This change adds an optional configuration parameter `signed_cookie` which defaults to false for backwards compatibility and does not use a signed cookie. The other two options are `true` to use a signed cookie and `:migrate` which converts unsigned cookies to signed ones and provides a safe transition path. You can set this via `Clearance.configure` in an initializer: ```ruby # ./config/initializers/clearance.rb Clearance.configure do |config| # ... config.signed_cookie = :migrate # ... end ``` This change also switched to using Rail's `ActionDispatch` for cookie handling rather than `Rack::Utils`. See related issues and pull requests: * #916 * Similar to #909 Co-authored-by: Yoav Aner <[email protected]> Co-authored-by: Eebs Kobeissi <[email protected]>
This commit introduces signed cookies into Clearance using the signed cookies functionality provided by [ActionDispatch](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html). By using a signed cookie an attacker cannot forge the cookie value, and therefore cannot perform timing attacks to find a valid token. See [this Rack security advisory](GHSA-hrqr-hxpp-chr3) for an example of the type of attack that could be possible with an injectable cookie value. This change adds an optional configuration parameter `signed_cookie` which defaults to false for backwards compatibility and does not use a signed cookie. The other two options are `true` to use a signed cookie and `:migrate` which converts unsigned cookies to signed ones and provides a safe transition path. You can set this via `Clearance.configure` in an initializer: ```ruby # ./config/initializers/clearance.rb Clearance.configure do |config| # ... config.signed_cookie = :migrate # ... end ``` This change also switched to using Rail's `ActionDispatch` for cookie handling rather than `Rack::Utils`. See related issues and pull requests: * #916 * Similar to #909 Co-authored-by: Yoav Aner <[email protected]> Co-authored-by: Eebs Kobeissi <[email protected]>
Closes #636
Before this commit, we were skipping any BCrypt (or other password
strategy) checks when we couldn't find the user by email. This opened up
the possibility for a bad actor to detect whether an account existed for
a given email address based on the timing of the response.
This commit mitigates the problem by creating a throw away user and
setting a dummy password on it, triggering BCrypt to create an encrypted
password.
The added tests were consistently failing before this change because
authentication for accounts that did NOT exists was taking 1-2ms longer
that for accounts that did exist (with the cost set to
::BCrypt::Engine::MIN_COST in tests). The timings are now very close.
The
be_within
delta could almost have been 0.0001, but I stuck with1ms to avoid any flakiness in the test suite.