Skip to content

[8.x] Patch for timeless timing attack vulnerability in user login #44069

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Illuminate/Auth/AuthManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ public function createSessionDriver($name, $config)
{
$provider = $this->createUserProvider($config['provider'] ?? null);

$guard = new SessionGuard($name, $provider, $this->app['session.store']);
$guard = new SessionGuard(
$name,
$provider,
$this->app['session.store'],
);

// When using the remember me functionality of the authentication services we
// will need to be set the encryption instance of the guard, which allows
Expand Down
37 changes: 31 additions & 6 deletions src/Illuminate/Auth/SessionGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Timebox;
use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
use RuntimeException;
Expand Down Expand Up @@ -88,6 +89,13 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
*/
protected $events;

/**
* The timebox instance.
*
* @var \Illuminate\Support\Timebox
*/
protected $timebox;

/**
* Indicates if the logout method has been called.
*
Expand All @@ -109,17 +117,20 @@ class SessionGuard implements StatefulGuard, SupportsBasicAuth
* @param \Illuminate\Contracts\Auth\UserProvider $provider
* @param \Illuminate\Contracts\Session\Session $session
* @param \Symfony\Component\HttpFoundation\Request|null $request
* @param \Illuminate\Support\Timebox|null $timebox
* @return void
*/
public function __construct($name,
UserProvider $provider,
Session $session,
Request $request = null)
Request $request = null,
Timebox $timebox = null)
{
$this->name = $name;
$this->session = $session;
$this->request = $request;
$this->provider = $provider;
$this->timebox = $timebox ?: new Timebox;
}

/**
Expand Down Expand Up @@ -419,13 +430,17 @@ public function attemptWhen(array $credentials = [], $callbacks = null, $remembe
*/
protected function hasValidCredentials($user, $credentials)
{
$validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
return $this->timebox->call(function ($timebox) use ($user, $credentials) {
$validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials);

if ($validated) {
$this->fireValidatedEvent($user);
}
if ($validated) {
$timebox->returnEarly();

$this->fireValidatedEvent($user);
}

return $validated;
return $validated;
}, 200 * 1000);
}

/**
Expand Down Expand Up @@ -953,4 +968,14 @@ public function setRequest(Request $request)

return $this;
}

/**
* Get the timebox instance used by the guard.
*
* @return \Illuminate\Support\Timebox
*/
public function getTimebox()
{
return $this->timebox;
}
}
70 changes: 70 additions & 0 deletions src/Illuminate/Support/Timebox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Illuminate\Support;

class Timebox
{
/**
* Indicates if the timebox is allowed to return early.
*
* @var bool
*/
public $earlyReturn = false;

/**
* Invoke the given callback within the specified timebox minimum.
*
* @param callable $callback
* @param int $microseconds
* @return mixed
*/
public function call(callable $callback, int $microseconds)
{
$start = microtime(true);

$result = $callback($this);

$remainder = $microseconds - ((microtime(true) - $start) * 1000000);

if (! $this->earlyReturn && $remainder > 0) {
$this->usleep($remainder);
}

return $result;
}

/**
* Indicate that the timebox can return early.
*
* @return $this
*/
public function returnEarly()
{
$this->earlyReturn = true;

return $this;
}

/**
* Indicate that the timebox cannot return early.
*
* @return $this
*/
public function dontReturnEarly()
{
$this->earlyReturn = false;

return $this;
}

/**
* Sleep for the specified number of microseconds.
*
* @param $microseconds
* @return void
*/
protected function usleep($microseconds)
{
usleep($microseconds);
}
}
46 changes: 36 additions & 10 deletions tests/Auth/AuthGuardTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Session\Session;
use Illuminate\Cookie\CookieJar;
use Illuminate\Support\Timebox;
use Mockery as m;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Cookie;
Expand Down Expand Up @@ -94,6 +95,10 @@ public function testAttemptCallsRetrieveByCredentials()
{
$guard = $this->getGuard();
$guard->setDispatcher($events = m::mock(Dispatcher::class));
$timebox = $guard->getTimebox();
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) {
return $callback($timebox);
});
$events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Failed::class));
$events->shouldNotReceive('dispatch')->with(m::type(Validated::class));
Expand All @@ -103,9 +108,12 @@ public function testAttemptCallsRetrieveByCredentials()

public function testAttemptReturnsUserInterface()
{
[$session, $provider, $request, $cookie] = $this->getMocks();
$guard = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['login'])->setConstructorArgs(['default', $provider, $session, $request])->getMock();
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();
$guard = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['login'])->setConstructorArgs(['default', $provider, $session, $request, $timebox])->getMock();
$guard->setDispatcher($events = m::mock(Dispatcher::class));
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) {
return $callback($timebox->shouldReceive('returnEarly')->once()->getMock());
});
$events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Validated::class));
$user = $this->createMock(Authenticatable::class);
Expand All @@ -119,6 +127,10 @@ public function testAttemptReturnsFalseIfUserNotGiven()
{
$mock = $this->getGuard();
$mock->setDispatcher($events = m::mock(Dispatcher::class));
$timebox = $mock->getTimebox();
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) {
return $callback($timebox);
});
$events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Failed::class));
$events->shouldNotReceive('dispatch')->with(m::type(Validated::class));
Expand All @@ -128,9 +140,12 @@ public function testAttemptReturnsFalseIfUserNotGiven()

public function testAttemptAndWithCallbacks()
{
[$session, $provider, $request, $cookie] = $this->getMocks();
$mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request])->getMock();
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();
$mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['getName'])->setConstructorArgs(['default', $provider, $session, $request, $timebox])->getMock();
$mock->setDispatcher($events = m::mock(Dispatcher::class));
$timebox->shouldReceive('call')->andReturnUsing(function ($callback) use ($timebox) {
return $callback($timebox->shouldReceive('returnEarly')->getMock());
});
$user = m::mock(Authenticatable::class);
$events->shouldReceive('dispatch')->times(3)->with(m::type(Attempting::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Login::class));
Expand Down Expand Up @@ -212,6 +227,10 @@ public function testFailedAttemptFiresFailedEvent()
{
$guard = $this->getGuard();
$guard->setDispatcher($events = m::mock(Dispatcher::class));
$timebox = $guard->getTimebox();
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback, $microseconds) use ($timebox) {
return $callback($timebox);
});
$events->shouldReceive('dispatch')->once()->with(m::type(Attempting::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Failed::class));
$events->shouldNotReceive('dispatch')->with(m::type(Validated::class));
Expand Down Expand Up @@ -544,9 +563,12 @@ public function testUserUsesRememberCookieIfItExists()

public function testLoginOnceSetsUser()
{
[$session, $provider, $request, $cookie] = $this->getMocks();
$guard = m::mock(SessionGuard::class, ['default', $provider, $session])->makePartial();
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();
$guard = m::mock(SessionGuard::class, ['default', $provider, $session, $request, $timebox])->makePartial();
$user = m::mock(Authenticatable::class);
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) {
return $callback($timebox->shouldReceive('returnEarly')->once()->getMock());
});
$guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user);
$guard->getProvider()->shouldReceive('validateCredentials')->once()->with($user, ['foo'])->andReturn(true);
$guard->shouldReceive('setUser')->once()->with($user);
Expand All @@ -555,19 +577,22 @@ public function testLoginOnceSetsUser()

public function testLoginOnceFailure()
{
[$session, $provider, $request, $cookie] = $this->getMocks();
$guard = m::mock(SessionGuard::class, ['default', $provider, $session])->makePartial();
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();
$guard = m::mock(SessionGuard::class, ['default', $provider, $session, $request, $timebox])->makePartial();
$user = m::mock(Authenticatable::class);
$timebox->shouldReceive('call')->once()->andReturnUsing(function ($callback) use ($timebox) {
return $callback($timebox);
});
$guard->getProvider()->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user);
$guard->getProvider()->shouldReceive('validateCredentials')->once()->with($user, ['foo'])->andReturn(false);
$this->assertFalse($guard->once(['foo']));
}

protected function getGuard()
{
[$session, $provider, $request, $cookie] = $this->getMocks();
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();

return new SessionGuard('default', $provider, $session, $request);
return new SessionGuard('default', $provider, $session, $request, $timebox);
}

protected function getMocks()
Expand All @@ -577,6 +602,7 @@ protected function getMocks()
m::mock(UserProvider::class),
Request::create('/', 'GET'),
m::mock(CookieJar::class),
m::mock(Timebox::class),
];
}

Expand Down
6 changes: 3 additions & 3 deletions tests/Support/SupportHelpersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ public function testRetry()
$this->assertEquals(2, $attempts);

// Make sure we waited 100ms for the first attempt
$this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.02);
$this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.03);
}

public function testRetryWithPassingSleepCallback()
Expand All @@ -573,7 +573,7 @@ public function testRetryWithPassingSleepCallback()
$this->assertEquals(3, $attempts);

// Make sure we waited 300ms for the first two attempts
$this->assertEqualsWithDelta(0.3, microtime(true) - $startTime, 0.02);
$this->assertEqualsWithDelta(0.3, microtime(true) - $startTime, 0.03);
}

public function testRetryWithPassingWhenCallback()
Expand All @@ -594,7 +594,7 @@ public function testRetryWithPassingWhenCallback()
$this->assertEquals(2, $attempts);

// Make sure we waited 100ms for the first attempt
$this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.02);
$this->assertEqualsWithDelta(0.1, microtime(true) - $startTime, 0.03);
}

public function testRetryWithFailingWhenCallback()
Expand Down
53 changes: 53 additions & 0 deletions tests/Support/SupportTimeboxTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Illuminate\Tests\Support;

use Illuminate\Support\Timebox;
use Mockery as m;
use PHPUnit\Framework\TestCase;

class SupportTimeboxTest extends TestCase
{
public function testMakeExecutesCallback()
{
$callback = function () {
$this->assertTrue(true);
};

(new Timebox)->call($callback, 0);
}

public function testMakeWaitsForMicroseconds()
{
$mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial();
$mock->shouldReceive('usleep')->once();

$mock->call(function () {
}, 10000);

$mock->shouldHaveReceived('usleep')->once();
}

public function testMakeShouldNotSleepWhenEarlyReturnHasBeenFlagged()
{
$mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial();
$mock->call(function ($timebox) {
$timebox->returnEarly();
}, 10000);

$mock->shouldNotHaveReceived('usleep');
}

public function testMakeShouldSleepWhenDontEarlyReturnHasBeenFlagged()
{
$mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial();
$mock->shouldReceive('usleep')->once();

$mock->call(function ($timebox) {
$timebox->returnEarly();
$timebox->dontReturnEarly();
}, 10000);

$mock->shouldHaveReceived('usleep')->once();
}
}