From bc26279cd59abc55f7737e359c8bed22a683d227 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 1 Aug 2026 09:32:30 -0400 Subject: [PATCH] Guard against out of order messages in the exchange. --- .../Sasl/Challenge/CramMD5Challenge.php | 6 ++++ tests/unit/Challenge/CramMD5ChallengeTest.php | 15 ++++++++++ .../unit/Challenge/DigestMD5ChallengeTest.php | 28 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php b/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php index 17c8a23..a0dc892 100644 --- a/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php +++ b/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php @@ -108,6 +108,12 @@ private function validateClientResponse( Message $received, CramMD5Options $options, ): void { + # The client sent a response without us sending a challenge... + if (!$this->context->has('challenge')) { + $this->context->setIsComplete(true); + + return; + } if (!$received->has('username')) { throw new SaslException('The client response must have a username.'); } diff --git a/tests/unit/Challenge/CramMD5ChallengeTest.php b/tests/unit/Challenge/CramMD5ChallengeTest.php index e5124f1..3c654ed 100644 --- a/tests/unit/Challenge/CramMD5ChallengeTest.php +++ b/tests/unit/Challenge/CramMD5ChallengeTest.php @@ -67,6 +67,21 @@ public function testChallengeWithFromServerWithClientCorrectResponse(): void self::assertTrue($context->isComplete()); } + public function testChallengeFromServerWithNoIssuedChallengeDoesNotAuthenticate(): void + { + $serverChallenge = new CramMD5Challenge(true); + $validate = static fn (string $username, string $challenge): string => hash_hmac('md5', $challenge, 'bar'); + + // A digest computed offline over an empty challenge, sent before the server issued one. + $context = $serverChallenge->challenge( + 'foo ' . hash_hmac('md5', '', 'bar'), + (new CramMD5Options())->setPasswordCallback(Closure::fromCallable($validate)), + ); + + self::assertFalse($context->isAuthenticated()); + self::assertTrue($context->isComplete()); + } + public function testPasswordCallableReceivesEncodedChallengeMatchingWhatClientUses(): void { $serverChallenge = new CramMD5Challenge(true); diff --git a/tests/unit/Challenge/DigestMD5ChallengeTest.php b/tests/unit/Challenge/DigestMD5ChallengeTest.php index 51839e2..523a286 100644 --- a/tests/unit/Challenge/DigestMD5ChallengeTest.php +++ b/tests/unit/Challenge/DigestMD5ChallengeTest.php @@ -16,6 +16,7 @@ use FreeDSx\Sasl\Challenge\DigestMD5Challenge; use FreeDSx\Sasl\Encoder\DigestMD5Encoder; use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Message; use FreeDSx\Sasl\Options\DigestMD5Options; use FreeDSx\Sasl\SaslContext; use PHPUnit\Framework\TestCase; @@ -119,6 +120,33 @@ public function testGenerateServerChallengeForClientInServerMode(): void self::assertNotEmpty($challenge->get('nonce'), 'The nonce must be generated.'); } + public function testServerModeWithNoIssuedChallengeDoesNotAuthenticate(): void + { + $serverChallenge = new DigestMD5Challenge(true); + + // A client response sent before the server issued its nonce. + $clientResponse = $this->encoder->encode( + new Message([ + 'username' => 'foo', + 'realm' => '', + 'nonce' => 'x', + 'cnonce' => str_repeat('a', 14), + 'nc' => '00000001', + 'qop' => 'auth', + 'digest-uri' => 'ldap/host', + 'response' => str_repeat('a', 32), + ]), + new SaslContext(), + ); + + $context = $serverChallenge->challenge( + $clientResponse, + (new DigestMD5Options())->setPassword('bar'), + ); + + self::assertFalse($context->isAuthenticated()); + } + public function testGenerateServerResponseToClientResponse(): void { self::markTestSkipped('Test does not work on newer PHP due to deprecated ciphers it seems. Needs investigation.');