From 024548dbad557f1ae16d39883c3c9ac2bffad964 Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 15 Sep 2026 22:55:07 +0200 Subject: [PATCH 1/3] chore --- config/packages/security.yaml | 11 ++++++---- .../Functional/Controllers/DashboardTest.php | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 26edd63f..659d01e4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -25,12 +25,15 @@ security: enable_csrf: true + # Only the first matching rule applies, so the public endpoints are listed first and the + # admin interface is whatever is left. A route added later is protected unless it is added + # to this list on purpose. access_control: - { path: ^/$, roles: PUBLIC_ACCESS } - { path: ^/dav, roles: PUBLIC_ACCESS } - - { path: ^/dashboard, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - - { path: ^/users, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - - { path: ^/addressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } + - { path: ^/\.well-known/, roles: PUBLIC_ACCESS } + - { path: ^/login$, roles: PUBLIC_ACCESS } + - { path: ^/logout$, roles: PUBLIC_ACCESS } - { path: ^/api/v1/health$, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED } + - { path: ^/, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } diff --git a/tests/Functional/Controllers/DashboardTest.php b/tests/Functional/Controllers/DashboardTest.php index 5913f4de..fa76c1ea 100644 --- a/tests/Functional/Controllers/DashboardTest.php +++ b/tests/Functional/Controllers/DashboardTest.php @@ -108,6 +108,26 @@ public function testLogoutRequiresACsrfToken(): void $this->assertResponseIsSuccessful('The session must survive a logout without a token'); } + /** + * The admin interface is protected by a terminal `^/` rule, so the endpoints that have to stay + * reachable without an account are the ones listed before it. If that list ever falls after the + * catch-all, clients can no longer discover or reach the DAV endpoint and nobody can log in. + */ + public function testThePublicEndpointsStayPublic(): void + { + $client = static::createClient(); + + foreach (['/', '/login', '/.well-known/caldav', '/.well-known/carddav'] as $url) { + $client->request('GET', $url); + + $this->assertNotSame( + '/login', + $client->getResponse()->headers->get('Location'), + $url.' must not redirect to the login page' + ); + } + } + public function testLogoutWorksFromTheMenuLink(): void { $client = static::createClient(); From ad895cd34e9c37770d152a537b44fd1b3e269abe Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 15 Sep 2026 23:20:05 +0200 Subject: [PATCH 2/3] chore --- composer.json | 1 + composer.lock | 76 ++++++++++++++++++- config/packages/security.yaml | 2 + config/reference.php | 2 +- docker/.env | 9 ++- src/Entity/AddressBook.php | 2 + src/Entity/CalendarInstance.php | 3 + src/Entity/CalendarObject.php | 12 --- src/Entity/Principal.php | 6 +- src/Security/LoginFormAuthenticator.php | 45 +++++------ src/Services/IMAPAuth.php | 23 +++--- .../Controllers/AddressBookControllerTest.php | 27 +++++++ .../Controllers/CalendarControllerTest.php | 27 +++++++ .../Functional/Controllers/DashboardTest.php | 50 ++++++++++++ 14 files changed, 232 insertions(+), 53 deletions(-) diff --git a/composer.json b/composer.json index 3f32ca18..23c3c7f0 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "symfony/process": "^7.4", "symfony/property-access": "^7.4", "symfony/property-info": "^7.4", + "symfony/rate-limiter": "7.4.*", "symfony/runtime": "^7.4", "symfony/security-bundle": "^7.4", "symfony/serializer": "^7.4", diff --git a/composer.lock b/composer.lock index 958dc633..ee0367b9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6ef4adb8a363d64ac0165dfd7eb5edae", + "content-hash": "cf307d135ad4b97d2a2c15847dd52a20", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -6832,6 +6832,80 @@ ], "time": "2026-08-25T14:18:37+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/6703d040ab83401b27f3c7b29ff4c8c8106fedba", + "reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T15:34:54+00:00" + }, { "name": "symfony/routing", "version": "v7.4.18", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 659d01e4..d91be49c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -18,6 +18,8 @@ security: custom_authenticators: - App\Security\LoginFormAuthenticator provider: admin_user_provider + login_throttling: + max_attempts: 5 logout: path: app_logout target: dashboard diff --git a/config/reference.php b/config/reference.php index 8af241e1..084008a4 100644 --- a/config/reference.php +++ b/config/reference.php @@ -629,7 +629,7 @@ * }>, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * limiters?: arraylastModifier; - } - - public function setLastModifier(?int $lastModifier): self - { - $this->lastModifier = $lastModifier; - - return $this; - } - public function getEtag(): ?string { return $this->etag; diff --git a/src/Entity/Principal.php b/src/Entity/Principal.php index a994ab22..281cc14d 100644 --- a/src/Entity/Principal.php +++ b/src/Entity/Principal.php @@ -24,24 +24,24 @@ class Principal private $id; #[ORM\Column(type: 'string', length: 255, unique: true)] - #[Assert\Unique] #[Assert\NotBlank] + #[Assert\Length(max: 255)] private $uri; #[ORM\Column(type: 'string', length: 255, nullable: true)] #[Assert\NotBlank] #[Assert\Email(message: "The email '{{ value }}' is not a valid email.")] + #[Assert\Length(max: 255)] private $email; #[ORM\Column(name: 'displayname', type: 'string', length: 255, nullable: true)] + #[Assert\Length(max: 255)] private $displayName; #[ORM\Column(type: 'boolean')] - #[Assert\NotBlank] private $isMain; #[ORM\Column(type: 'boolean')] - #[Assert\NotBlank] private $isAdmin; #[ORM\ManyToMany(targetEntity: 'Principal')] diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index 0d314c61..3704cb60 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -7,13 +7,12 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; -use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\SecurityRequestAttributes; use Symfony\Component\Security\Http\Util\TargetPathTrait; @@ -46,31 +45,33 @@ public function supports(Request $request): bool && $request->isMethod('POST'); } + /** + * The credentials are rejected by the passport rather than by throwing from here, because + * `login_throttling` only gets to run once a passport exists: throwing earlier means failed + * attempts are counted but never blocked. + */ public function authenticate(Request $request): Passport { - $credentials = [ - 'username' => $request->request->get('_username'), - 'password' => $request->request->get('_password'), - 'csrf_token' => $request->request->get('_csrf_token'), - ]; - $request->getSession()->set( - SecurityRequestAttributes::LAST_USERNAME, - $credentials['username'] - ); + $username = $request->request->getString('_username'); + $password = $request->request->getString('_password'); - if ($credentials['username'] !== $this->adminLogin) { - // fail authentication with a custom error - throw new CustomUserMessageAuthenticationException('Username could not be found.'); - } + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username); - if ($credentials['password'] !== $this->adminPassword) { - // fail authentication with a custom error - throw new CustomUserMessageAuthenticationException('Invalid credentials.'); - } + return new Passport( + new UserBadge($username), + new CustomCredentials( + function (string $presentedPassword) use ($username): bool { + // Both halves are compared, and both in constant time, so the response says + // nothing about which one was wrong — an unknown name and a wrong password + // fail identically with "Invalid credentials.". + $loginMatches = hash_equals($this->adminLogin, $username); + $passwordMatches = hash_equals($this->adminPassword, $presentedPassword); - return new SelfValidatingPassport( - new UserBadge($this->adminLogin), - [new CsrfTokenBadge('authenticate', $credentials['csrf_token'])] + return $loginMatches && $passwordMatches; + }, + $password + ), + [new CsrfTokenBadge('authenticate', $request->request->getString('_csrf_token'))] ); } diff --git a/src/Services/IMAPAuth.php b/src/Services/IMAPAuth.php index 51cf4e7d..5b1d6e3c 100644 --- a/src/Services/IMAPAuth.php +++ b/src/Services/IMAPAuth.php @@ -64,21 +64,11 @@ public function __construct(ManagerRegistry $doctrine, Utils $utils, string $IMA $components = parse_url($IMAPAuthUrl); if (!$components) { - throw new Exception('IMAP Error (parsing IMAP url "'.$IMAPAuthUrl.'"): '.$e->getMessage()); + throw new \RuntimeException('IMAP_AUTH_URL could not be parsed as a URL: "'.$IMAPAuthUrl.'". Expected something like "imap.example.com:993".'); } $this->IMAPHost = $components['host'] ?? null; - // Trying to choose the best port if it was not provided, - // defaulting to 993 (secure) - if (isset($components['port'])) { - $this->IMAPPort = $components['port']; - } elseif (false === $this->IMAPEncryptionMethod) { - $this->IMAPPort = 143; - } else { - $this->IMAPPort = 993; - } - // We're making sure that only ssl, tls or 'false' are passed down to the IMAP client, // defaulting to SSL $IMAPEncryptionMethodCleaned = strtolower($IMAPEncryptionMethod); @@ -89,6 +79,17 @@ public function __construct(ManagerRegistry $doctrine, Utils $utils, string $IMA } else { $this->IMAPEncryptionMethod = 'ssl'; } + + // Trying to choose the best port if it was not provided, + // defaulting to 993 (secure) + if (isset($components['port'])) { + $this->IMAPPort = $components['port']; + } elseif (false === $this->IMAPEncryptionMethod) { + $this->IMAPPort = 143; + } else { + $this->IMAPPort = 993; + } + $this->IMAPCertificateValidation = $IMAPCertificateValidation; $this->autoCreate = $autoCreate; diff --git a/tests/Functional/Controllers/AddressBookControllerTest.php b/tests/Functional/Controllers/AddressBookControllerTest.php index d896c721..42971435 100644 --- a/tests/Functional/Controllers/AddressBookControllerTest.php +++ b/tests/Functional/Controllers/AddressBookControllerTest.php @@ -97,6 +97,33 @@ public function testAddressBookNew(): void $this->assertAnySelectorTextContains('h5', 'New test address book'); } + /** + * `uri` and `displayname` are 255-char columns, so an over-long value has to be refused by the + * form. Without a length constraint it reaches the driver and comes back as a 500. + */ + public function testAddressBookNewRefusesOverLongValues(): void + { + $client = static::createClient(); + $client->loginUser(new AdminUser('admin', 'test')); + + $userId = $this->getUserId($client, 'test_user'); + + $crawler = $client->request('GET', '/addressbooks/'.$userId.'/new'); + $form = $crawler->selectButton('address_book_save')->form(); + + $client->submit($form, [ + 'address_book[uri]' => str_repeat('a', 256), + 'address_book[displayName]' => str_repeat('b', 256), + 'address_book[description]' => 'too long', + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('.invalid-feedback, .form-error-message'); + + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + $this->assertNull($em->getRepository(AddressBook::class)->findOneBy(['uri' => str_repeat('a', 256)])); + } + public function testAddressBookDelete(): void { $user = new AdminUser('admin', 'test'); diff --git a/tests/Functional/Controllers/CalendarControllerTest.php b/tests/Functional/Controllers/CalendarControllerTest.php index 3ffe1aee..d7d4e190 100644 --- a/tests/Functional/Controllers/CalendarControllerTest.php +++ b/tests/Functional/Controllers/CalendarControllerTest.php @@ -291,6 +291,33 @@ public function testRevokingAnOwnerInstanceIsRejected(): void $this->assertNotNull($calendarRepository->find($calendar->getId())); } + /** + * `uri` and `displayname` are 255 characters wide and `calendarcolor` is 10, so an over-long + * value has to come back as a form error rather than as a truncation error from the driver. + */ + public function testCalendarNewRefusesOverLongValues(): void + { + $client = $this->loggedInClient(); + + $userId = $this->getUserId($client, 'test_user'); + + $crawler = $client->request('GET', '/calendars/'.$userId.'/new'); + $form = $crawler->selectButton('calendar_instance_save')->form(); + + $client->submit($form, [ + 'calendar_instance[uri]' => str_repeat('a', 256), + 'calendar_instance[displayName]' => str_repeat('b', 256), + 'calendar_instance[description]' => 'too long', + 'calendar_instance[calendarColor]' => '#ABCDEF'.str_repeat('0', 20), + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('.invalid-feedback, .form-error-message'); + + $em = static::getContainer()->get('doctrine.orm.entity_manager'); + $this->assertNull($em->getRepository(CalendarInstance::class)->findOneBy(['uri' => str_repeat('a', 256)])); + } + public function testCalendarNewIgnoresASubmittedOwner(): void { $client = $this->loggedInClient(); diff --git a/tests/Functional/Controllers/DashboardTest.php b/tests/Functional/Controllers/DashboardTest.php index fa76c1ea..21d77e81 100644 --- a/tests/Functional/Controllers/DashboardTest.php +++ b/tests/Functional/Controllers/DashboardTest.php @@ -72,6 +72,56 @@ public function testLoginIncorrectPassword(): void $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.'); } + /** + * The login throttling budget is keyed on the username *and* the client IP, and it outlives + * the kernel because it lives in a cache pool. Giving each test its own random IP keeps one + * test from spending another's budget, including across repeated local runs. + */ + private function submitLogin($client, string $username, string $password): void + { + $crawler = $client->request('GET', '/login'); + + $form = $crawler->selectButton('Submit')->form(); + $form['_username']->setValue($username); + $form['_password']->setValue($password); + + $client->submit($form); + } + + public function testRepeatedFailedLoginsAreThrottled(): void + { + $client = static::createClient(); + $client->setServerParameter('REMOTE_ADDR', '10.'.random_int(0, 255).'.'.random_int(0, 255).'.'.random_int(1, 254)); + + for ($i = 1; $i <= 5; ++$i) { + $this->submitLogin($client, $_ENV['ADMIN_LOGIN'], 'bad_password'); + $client->followRedirect(); + $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.', 'attempt '.$i.' should still be answered normally'); + } + + $this->submitLogin($client, $_ENV['ADMIN_LOGIN'], 'bad_password'); + $client->followRedirect(); + + $this->assertSelectorTextContains('div.alert.alert-danger', 'Too many failed login attempts'); + } + + /** + * The budget is per username and IP, so a few typos must not lock the admin out. + */ + public function testACorrectLoginStillWorksAfterAFewFailedAttempts(): void + { + $client = static::createClient(); + $client->setServerParameter('REMOTE_ADDR', '10.'.random_int(0, 255).'.'.random_int(0, 255).'.'.random_int(1, 254)); + + for ($i = 1; $i <= 4; ++$i) { + $this->submitLogin($client, $_ENV['ADMIN_LOGIN'], 'bad_password'); + } + + $this->submitLogin($client, $_ENV['ADMIN_LOGIN'], $_ENV['ADMIN_PASSWORD']); + + $this->assertResponseRedirects('/dashboard'); + } + public function testLoginCorrect(): void { $client = static::createClient(); From 5382b2256dd59694075dc02769ee65675b475949 Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 15 Sep 2026 23:45:01 +0200 Subject: [PATCH 3/3] imap --- .env | 2 +- src/Services/IMAPAuth.php | 27 +++++- tests/Functional/Service/IMAPAuthTest.php | 111 ++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/Functional/Service/IMAPAuthTest.php diff --git a/.env b/.env index 47f3c7ff..17efeca8 100644 --- a/.env +++ b/.env @@ -49,7 +49,7 @@ AUTH_REALM=SabreDAV AUTH_METHOD=Basic # In case of IMAP Auth, you must specify the url of the mailbox in the following format host[:port]. -IMAP_AUTH_URL=null +IMAP_AUTH_URL= IMAP_ENCRYPTION_METHOD=ssl IMAP_CERTIFICATE_VALIDATION=true IMAP_AUTH_USER_AUTOCREATE=false diff --git a/src/Services/IMAPAuth.php b/src/Services/IMAPAuth.php index 5b1d6e3c..cbce8a6e 100644 --- a/src/Services/IMAPAuth.php +++ b/src/Services/IMAPAuth.php @@ -61,10 +61,23 @@ final class IMAPAuth extends AbstractAuth public function __construct(ManagerRegistry $doctrine, Utils $utils, string $IMAPAuthUrl, bool $autoCreate, string $IMAPEncryptionMethod, bool $IMAPCertificateValidation) { - $components = parse_url($IMAPAuthUrl); + // `.env` shipped `IMAP_AUTH_URL=null` as a placeholder, and a dotenv file has no null + // literal, so it arrives as the four-letter string. Left alone it would be taken for a + // hostname and every login would fail on name resolution instead of saying it is unset. + if ('null' === strtolower(trim($IMAPAuthUrl))) { + $IMAPAuthUrl = ''; + } + + // IMAP_AUTH_URL is documented as `host:port`, but parse_url() only reads a host when the + // value looks like an authority: a bare `imap.example.com` lands in `path` instead. The + // value is reduced to that authority — leading slashes off, scheme off — and then given + // the `//` that makes parse_url read it as a host. The scheme carries no meaning here + // anyway: encryption comes from IMAP_ENCRYPTION_METHOD. + $authority = preg_replace('~^[a-z][a-z0-9+.-]*://~i', '', ltrim($IMAPAuthUrl, '/')); + $components = '' === $IMAPAuthUrl ? [] : parse_url('//'.$authority); - if (!$components) { - throw new \RuntimeException('IMAP_AUTH_URL could not be parsed as a URL: "'.$IMAPAuthUrl.'". Expected something like "imap.example.com:993".'); + if (false === $components) { + throw new \RuntimeException('IMAP_AUTH_URL could not be parsed: "'.$IMAPAuthUrl.'". Expected something like "imap.example.com:993".'); } $this->IMAPHost = $components['host'] ?? null; @@ -104,6 +117,14 @@ public function __construct(ManagerRegistry $doctrine, Utils $utils, string $IMA */ protected function imapOpen(string $username, string $password): bool { + // Reported here rather than from the constructor: this backend is a constructor argument of + // the DAV controller, so it is built on every request even when AUTH_METHOD is not IMAP. + if (!$this->IMAPHost) { + error_log('IMAP Error (configuration): IMAP_AUTH_URL has no host, expected something like "imap.example.com:993".'); + + return false; + } + $cm = new ClientManager($options = []); // Create a new instance of the IMAP client manually diff --git a/tests/Functional/Service/IMAPAuthTest.php b/tests/Functional/Service/IMAPAuthTest.php new file mode 100644 index 00000000..7bb89e35 --- /dev/null +++ b/tests/Functional/Service/IMAPAuthTest.php @@ -0,0 +1,111 @@ +get(ManagerRegistry::class), + $container->get(Utils::class), + $url, + false, + $encryptionMethod, + true + ); + } + + /** + * @return array{0: ?string, 1: ?int} + */ + private function hostAndPortOf(IMAPAuth $backend): array + { + $reflection = new \ReflectionObject($backend); + + return [ + $reflection->getProperty('IMAPHost')->getValue($backend), + $reflection->getProperty('IMAPPort')->getValue($backend), + ]; + } + + /** + * @dataProvider authUrls + */ + public function testTheHostAndPortAreReadFromTheAuthUrl(string $url, ?string $host, int $port): void + { + [$parsedHost, $parsedPort] = $this->hostAndPortOf($this->backendFor($url)); + + $this->assertSame($host, $parsedHost, $url.' should yield that host'); + $this->assertSame($port, $parsedPort, $url.' should yield that port'); + } + + public static function authUrls(): iterable + { + yield 'the documented host:port form' => ['imap.example.com:993', 'imap.example.com', 993]; + yield 'a bare host' => ['imap.example.com', 'imap.example.com', 993]; + yield 'a scheme is ignored, the port is kept' => ['imaps://imap.example.com:993', 'imap.example.com', 993]; + yield 'a scheme without a port' => ['ssl://imap.example.com', 'imap.example.com', 993]; + yield 'an IPv6 literal' => ['[::1]:993', '[::1]', 993]; + yield 'a trailing path is not part of the host' => ['imap.example.com//foo', 'imap.example.com', 993]; + yield 'a leading double slash' => ['//imap.example.com:993', 'imap.example.com', 993]; + yield 'a leading double slash in front of a scheme' => ['//imaps://imap.example.com', 'imap.example.com', 993]; + yield 'the null placeholder counts as unset, not as a hostname' => ['null', null, 993]; + yield 'an empty value leaves no host' => ['', null, 993]; + } + + /** + * The port defaults to the one matching the encryption method whenever the url omits it. + */ + public function testTheDefaultPortFollowsTheEncryptionMethod(): void + { + $this->assertSame(143, $this->hostAndPortOf($this->backendFor('imap.example.com', 'false'))[1]); + $this->assertSame(993, $this->hostAndPortOf($this->backendFor('imap.example.com', 'tls'))[1]); + $this->assertSame(993, $this->hostAndPortOf($this->backendFor('imap.example.com', 'ssl'))[1]); + + // An explicit port always wins over the default + $this->assertSame(1143, $this->hostAndPortOf($this->backendFor('imap.example.com:1143', 'false'))[1]); + } + + public function testAnUnparsableAuthUrlIsReportedClearly(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('IMAP_AUTH_URL could not be parsed'); + + $this->backendFor('///'); + } + + /** + * The backend is a constructor argument of the DAV controller, so it is built on every request + * even when AUTH_METHOD is not IMAP. A missing host therefore cannot fail at boot, and has to + * deny the login instead of opening a connection to nowhere. + */ + public function testALoginIsDeniedWhenTheAuthUrlCarriesNoHost(): void + { + $backend = $this->backendFor(''); + + $log = tempnam(sys_get_temp_dir(), 'imap-auth-test'); + $previous = ini_set('error_log', $log); + + try { + $denied = (new \ReflectionMethod($backend, 'imapOpen'))->invoke($backend, 'someone', 'password'); + $logged = file_get_contents($log); + } finally { + ini_set('error_log', $previous); + unlink($log); + } + + $this->assertFalse($denied); + $this->assertStringContainsString('IMAP_AUTH_URL has no host', $logged, 'The reason has to reach the log'); + } +}