|
|
@@ -88,6 +88,65 @@ final class LocalLoginTest extends AppTestCase
|
|
|
self::assertFalse($throttle->isLocked('someone', ''));
|
|
|
}
|
|
|
|
|
|
+ public function testWrongUsernameTriggersPasswordVerify(): void
|
|
|
+ {
|
|
|
+ // SEC_REVIEW F7: a wrong username must still go through
|
|
|
+ // password_verify against an Argon2id hash, so timing does not
|
|
|
+ // distinguish "username matches" from "username does not match".
|
|
|
+ // We assert a generous lower bound (10 ms) — well above the
|
|
|
+ // microsecond cost of a path that skips password_verify, and
|
|
|
+ // well below PHP's default PASSWORD_ARGON2ID cost (~50 ms+).
|
|
|
+ $this->request('GET', '/login');
|
|
|
+ $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
|
|
|
+
|
|
|
+ $body = http_build_query([
|
|
|
+ 'csrf_token' => $token,
|
|
|
+ 'username' => 'definitely_not_the_admin',
|
|
|
+ 'password' => 'irrelevant',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $start = hrtime(true);
|
|
|
+ $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
|
|
|
+ $elapsedNs = hrtime(true) - $start;
|
|
|
+
|
|
|
+ self::assertSame(303, $response->getStatusCode());
|
|
|
+ self::assertGreaterThan(
|
|
|
+ 10_000_000,
|
|
|
+ $elapsedNs,
|
|
|
+ 'wrong-username path took <10ms; password_verify likely skipped (F7 regression)',
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testUnconfiguredLocalPasswordHashStillRunsPasswordVerify(): void
|
|
|
+ {
|
|
|
+ // SEC_REVIEW F7 defence-in-depth: even when LOCAL_ADMIN_PASSWORD_HASH
|
|
|
+ // is empty, the login path must run password_verify against the
|
|
|
+ // dummy Argon2id hash so an attacker cannot probe whether a local
|
|
|
+ // admin password is configured by timing.
|
|
|
+ $this->bootApp(['local_admin_password_hash' => '']);
|
|
|
+
|
|
|
+ $this->request('GET', '/login');
|
|
|
+ $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
|
|
|
+
|
|
|
+ $body = http_build_query([
|
|
|
+ 'csrf_token' => $token,
|
|
|
+ 'username' => 'admin',
|
|
|
+ 'password' => 'whatever',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $start = hrtime(true);
|
|
|
+ $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
|
|
|
+ $elapsedNs = hrtime(true) - $start;
|
|
|
+
|
|
|
+ self::assertSame(303, $response->getStatusCode());
|
|
|
+ self::assertSame('/login', $response->getHeaderLine('Location'));
|
|
|
+ self::assertGreaterThan(
|
|
|
+ 10_000_000,
|
|
|
+ $elapsedNs,
|
|
|
+ 'unconfigured-hash path took <10ms; dummy password_verify likely skipped (F7 regression)',
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
public function testCsrfMissingIs403(): void
|
|
|
{
|
|
|
$this->request('GET', '/login');
|