Skip to content

Commit e653b30

Browse files
gingerlimeYoav Anereebs
authored andcommitted
Prevent remember_token timing attacks (#917)
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 <yoav@gingerlime.com> Co-authored-by: Eebs Kobeissi <ebrahim.kobeissi@gmail.com>
1 parent 77c9d2d commit e653b30

8 files changed

Lines changed: 188 additions & 63 deletions

File tree

lib/clearance/configuration.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ class Configuration
8888
# @return [Boolean]
8989
attr_accessor :secure_cookie
9090

91+
# Controls whether cookies are signed.
92+
# Defaults to `false` for backwards compatibility.
93+
# When set, uses Rails' signed cookies
94+
# (more secure against timing/brute-force attacks)
95+
# see [ActionDispatch::Cookies](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html)
96+
# @return [Boolean|:migrate]
97+
attr_reader :signed_cookie
98+
9199
# The array of sign in guards to run when signing a user in.
92100
# Defaults to an empty array. Sign in guards respond to `call` and are
93101
# initialized with a session and the current stack. Each guard can decide
@@ -124,9 +132,20 @@ def initialize
124132
@rotate_csrf_on_sign_in = true
125133
@routes = true
126134
@secure_cookie = false
135+
@signed_cookie = false
127136
@sign_in_guards = []
128137
end
129138

139+
def signed_cookie=(value)
140+
if [true, false, :migrate].include? value
141+
@signed_cookie = value
142+
else
143+
raise "Clearance's signed_cookie configuration value is invalid. " \
144+
"Valid values are true, false, or :migrate. " \
145+
"Set this option via Clearance.configure in an initializer"
146+
end
147+
end
148+
130149
# The class representing the configured user model.
131150
# In the default configuration, this is the `User` class.
132151
# @return [Class]

lib/clearance/rack_session.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def call(env)
2323
response = @app.call(env)
2424

2525
if session.authentication_successful?
26-
session.add_cookie_to_headers response[1]
26+
session.add_cookie_to_headers
2727
end
2828

2929
response

lib/clearance/session.rb

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,9 @@ def initialize(env)
1414
# Called by {RackSession} to add the Clearance session cookie to a response.
1515
#
1616
# @return [void]
17-
def add_cookie_to_headers(headers)
17+
def add_cookie_to_headers
1818
if signed_in_with_remember_token?
19-
Rack::Utils.set_cookie_header!(
20-
headers,
21-
remember_token_cookie,
22-
cookie_options.merge(
23-
value: current_user.remember_token,
24-
),
25-
)
19+
set_remember_token(current_user.remember_token)
2620
end
2721
end
2822

@@ -112,9 +106,27 @@ def cookies
112106
@cookies ||= ActionDispatch::Request.new(@env).cookie_jar
113107
end
114108

109+
# @api private
110+
def set_remember_token(token)
111+
case Clearance.configuration.signed_cookie
112+
when true, :migrate
113+
cookies.signed[remember_token_cookie] = cookie_options(token)
114+
when false
115+
cookies[remember_token_cookie] = cookie_options(token)
116+
end
117+
remember_token
118+
end
119+
115120
# @api private
116121
def remember_token
117-
cookies[remember_token_cookie]
122+
case Clearance.configuration.signed_cookie
123+
when true
124+
cookies.signed[remember_token_cookie]
125+
when :migrate
126+
cookies.signed[remember_token_cookie] || cookies[remember_token_cookie]
127+
when false
128+
cookies[remember_token_cookie]
129+
end
118130
end
119131

120132
# @api private
@@ -159,15 +171,15 @@ def initialize_sign_in_guard_stack
159171
end
160172

161173
# @api private
162-
def cookie_options
174+
def cookie_options(value)
163175
{
164176
domain: domain,
165177
expires: remember_token_expires,
166178
httponly: Clearance.configuration.httponly,
167179
same_site: Clearance.configuration.same_site,
168180
path: Clearance.configuration.cookie_path,
169181
secure: Clearance.configuration.secure_cookie,
170-
value: remember_token,
182+
value: value,
171183
}
172184
end
173185

spec/clearance/rack_session_spec.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
response = Rack::MockResponse.new(*app.call(env))
2121

2222
expect(response.body).to eq expected_session
23-
expect(expected_session).to have_received(:add_cookie_to_headers).
24-
with(hash_including(headers))
23+
expect(expected_session).to have_received(:add_cookie_to_headers)
2524
end
2625
end

spec/clearance/session_spec.rb

Lines changed: 97 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
before { Timecop.freeze }
55
after { Timecop.return }
66

7-
let(:headers) { {} }
87
let(:session) { Clearance::Session.new(env_without_remember_token) }
98
let(:user) { create(:user) }
109

@@ -35,9 +34,63 @@
3534
Clearance.configuration.cookie_name = "custom_cookie_name"
3635

3736
session.sign_in user
38-
session.add_cookie_to_headers(headers)
37+
session.add_cookie_to_headers
3938

40-
expect(headers["Set-Cookie"]).to match(/custom_cookie_name=.+;/)
39+
expect(remember_token_cookie(session, "custom_cookie_name")).to be_present
40+
end
41+
end
42+
43+
context "with signed cookies == false" do
44+
it "uses cookies.signed" do
45+
Clearance.configuration.signed_cookie = true
46+
47+
cookie_jar = {}
48+
expect(session).to receive(:cookies).and_return(cookie_jar)
49+
expect(cookie_jar).to receive(:signed).and_return(cookie_jar)
50+
51+
session.sign_in user
52+
end
53+
end
54+
55+
context "with signed cookies == true" do
56+
it "uses cookies.signed" do
57+
Clearance.configuration.signed_cookie = true
58+
59+
cookie_jar = {}
60+
expect(session).to receive(:cookies).and_return(cookie_jar)
61+
expect(cookie_jar).to receive(:signed).and_return(cookie_jar)
62+
63+
session.sign_in user
64+
end
65+
end
66+
67+
context "with signed cookies == :migrate" do
68+
before do
69+
Clearance.configuration.signed_cookie = :migrate
70+
end
71+
72+
context "signed cookie exists" do
73+
it "uses cookies.signed[remember_token]" do
74+
cookie_jar = { "remember_token" => "signed cookie" }
75+
expect(session).to receive(:cookies).and_return(cookie_jar)
76+
expect(cookie_jar).to receive(:signed).and_return(cookie_jar)
77+
78+
session.sign_in user
79+
end
80+
end
81+
82+
context "signed cookie does not exist yet" do
83+
it "uses cookies[remember_token] instead" do
84+
cookie_jar = { "remember_token" => "signed cookie" }
85+
# first call will try to get the signed cookie
86+
expect(session).to receive(:cookies).and_return(cookie_jar)
87+
# ... but signed_cookie doesn't exist
88+
expect(cookie_jar).to receive(:signed).and_return({})
89+
# then it attempts to retrieve the unsigned cookie
90+
expect(session).to receive(:cookies).and_return(cookie_jar)
91+
92+
session.sign_in user
93+
end
4194
end
4295
end
4396

@@ -157,9 +210,9 @@ def stub_status(success)
157210
end
158211

159212
it 'sets a httponly cookie' do
160-
session.add_cookie_to_headers(headers)
213+
session.add_cookie_to_headers
161214

162-
expect(headers['Set-Cookie']).to match(/remember_token=.+; HttpOnly/)
215+
expect(remember_token_cookie(session)[:httponly]).to be_truthy
163216
end
164217
end
165218

@@ -170,9 +223,9 @@ def stub_status(success)
170223
end
171224

172225
it 'sets a standard cookie' do
173-
session.add_cookie_to_headers(headers)
226+
session.add_cookie_to_headers
174227

175-
expect(headers['Set-Cookie']).not_to match(/remember_token=.+; HttpOnly/)
228+
expect(remember_token_cookie(session)[:httponly]).to be_falsey
176229
end
177230
end
178231

@@ -183,9 +236,9 @@ def stub_status(success)
183236
end
184237

185238
it "sets a same-site cookie" do
186-
session.add_cookie_to_headers(headers)
239+
session.add_cookie_to_headers
187240

188-
expect(headers["Set-Cookie"]).to match(/remember_token=.+; SameSite/)
241+
expect(remember_token_cookie(session)[:same_site]).to eq(:lax)
189242
end
190243
end
191244

@@ -195,25 +248,21 @@ def stub_status(success)
195248
end
196249

197250
it "sets a standard cookie" do
198-
session.add_cookie_to_headers(headers)
251+
session.add_cookie_to_headers
199252

200-
expect(headers["Set-Cookie"]).to_not match(/remember_token=.+; SameSite/)
253+
expect(remember_token_cookie(session)[:same_site]).to be_nil
201254
end
202255
end
203256

204257
describe 'remember token cookie expiration' do
205258
context 'default configuration' do
206259
it 'is set to 1 year from now' do
207260
user = double("User", remember_token: "123abc")
208-
headers = {}
209261
session = Clearance::Session.new(env_without_remember_token)
210262
session.sign_in user
211-
session.add_cookie_to_headers headers
263+
session.add_cookie_to_headers
212264

213-
expect(headers).to set_cookie(
214-
'remember_token',
215-
user.remember_token, 1.year.from_now
216-
)
265+
expect(remember_token_cookie(session)[:expires]).to eq(1.year.from_now)
217266
end
218267
end
219268

@@ -225,18 +274,14 @@ def stub_status(success)
225274
end
226275
with_custom_expiration expires_at do
227276
user = double("User", remember_token: "123abc")
228-
headers = {}
229277
environment = env_with_cookies(remember_me: 'true')
230278
session = Clearance::Session.new(environment)
231279
session.sign_in user
232-
session.add_cookie_to_headers headers
233-
234-
expect(headers).to set_cookie(
235-
'remember_token',
236-
user.remember_token,
237-
remembered_expires
238-
)
239-
280+
session.add_cookie_to_headers
281+
expect(remember_token_cookie(session)[:expires]).to \
282+
eq(remembered_expires)
283+
expect(remember_token_cookie(session)[:value]).to \
284+
eq(user.remember_token)
240285
end
241286
end
242287
end
@@ -249,9 +294,9 @@ def stub_status(success)
249294
end
250295

251296
it 'sets a standard cookie' do
252-
session.add_cookie_to_headers(headers)
297+
session.add_cookie_to_headers
253298

254-
expect(headers['Set-Cookie']).not_to match(/remember_token=.+; secure/)
299+
expect(remember_token_cookie(session)[:secure]).to be_falsey
255300
end
256301
end
257302

@@ -262,9 +307,9 @@ def stub_status(success)
262307
end
263308

264309
it 'sets a secure cookie' do
265-
session.add_cookie_to_headers(headers)
310+
session.add_cookie_to_headers
266311

267-
expect(headers['Set-Cookie']).to match(/remember_token=.+; secure/)
312+
expect(remember_token_cookie(session)[:secure]).to be_truthy
268313
end
269314
end
270315
end
@@ -280,19 +325,19 @@ def stub_status(success)
280325
let(:cookie_domain) { ".example.com" }
281326

282327
it "sets a standard cookie" do
283-
session.add_cookie_to_headers(headers)
328+
session.add_cookie_to_headers
284329

285-
expect(headers['Set-Cookie']).to match(/domain=\.example\.com; path/)
330+
expect(remember_token_cookie(session)[:domain]).to eq(cookie_domain)
286331
end
287332
end
288333

289334
context "with lambda" do
290335
let(:cookie_domain) { lambda { |_r| ".example.com" } }
291336

292337
it "sets a standard cookie" do
293-
session.add_cookie_to_headers(headers)
338+
session.add_cookie_to_headers
294339

295-
expect(headers['Set-Cookie']).to match(/domain=\.example\.com; path/)
340+
expect(remember_token_cookie(session)[:domain]).to eq(".example.com")
296341
end
297342
end
298343
end
@@ -301,9 +346,9 @@ def stub_status(success)
301346
before { session.sign_in(user) }
302347

303348
it 'sets a standard cookie' do
304-
session.add_cookie_to_headers(headers)
349+
session.add_cookie_to_headers
305350

306-
expect(headers["Set-Cookie"]).not_to match(/domain=.+; path/)
351+
expect(remember_token_cookie(session)[:domain]).to be_nil
307352
end
308353
end
309354
end
@@ -313,9 +358,9 @@ def stub_status(success)
313358
before { session.sign_in(user) }
314359

315360
it 'sets a standard cookie' do
316-
session.add_cookie_to_headers(headers)
361+
session.add_cookie_to_headers
317362

318-
expect(headers["Set-Cookie"]).to_not match(/domain=.+; path/)
363+
expect(remember_token_cookie(session)[:domain]).to be_nil
319364
end
320365
end
321366

@@ -326,18 +371,17 @@ def stub_status(success)
326371
end
327372

328373
it 'sets a standard cookie' do
329-
session.add_cookie_to_headers(headers)
374+
session.add_cookie_to_headers
330375

331-
expect(headers['Set-Cookie']).to match(/path=\/user; expires/)
376+
expect(remember_token_cookie(session)[:path]).to eq("/user")
332377
end
333378
end
334379
end
335380

336381
it 'does not set a remember token when signed out' do
337-
headers = {}
338382
session = Clearance::Session.new(env_without_remember_token)
339-
session.add_cookie_to_headers headers
340-
expect(headers["Set-Cookie"]).to be nil
383+
session.add_cookie_to_headers
384+
expect(remember_token_cookie(session)).to be_nil
341385
end
342386

343387
describe "#sign_out" do
@@ -399,6 +443,16 @@ def stub_status(success)
399443
end
400444
end
401445

446+
# a bit of a hack to get the cookies that ActionDispatch sets inside session
447+
def remember_token_cookie(session, cookie_name = "remember_token")
448+
cookies = session.send(:cookies)
449+
# see https://stackoverflow.com/a/21315095
450+
set_cookies = cookies.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
451+
@set_cookies
452+
RUBY
453+
set_cookies[cookie_name]
454+
end
455+
402456
def env_with_cookies(cookies)
403457
Rack::MockRequest.env_for '/', 'HTTP_COOKIE' => serialize_cookies(cookies)
404458
end

0 commit comments

Comments
 (0)