get('doctrine.orm.entity_manager'); $instance = $em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => Principal::PREFIX.'test_user', 'uri' => 'default', ]); $this->assertNotNull($instance, 'Fixture calendar for test_user is missing'); $object = (new CalendarObject()) ->setCalendar($instance->getCalendar()) ->setUri(self::SECRET_OBJECT_URI) ->setCalendarData(self::SECRET_CALENDAR_DATA) ->setEtag(md5(self::SECRET_CALENDAR_DATA)) ->setSize(strlen(self::SECRET_CALENDAR_DATA)) ->setComponentType('VEVENT') ->setUid('secret-1') ->setLastModified(time()) ->setFirstOccurence((int) strtotime('2026-01-01T10:00:00Z')) ->setLastOccurence((int) strtotime('2026-01-01T11:00:00Z')); $em->persist($object); $em->flush(); $em->clear(); } private function getSecretCalendarData(): ?string { /** @var EntityManagerInterface $em */ $em = static::getContainer()->get('doctrine.orm.entity_manager'); $em->clear(); $object = $em->getRepository(CalendarObject::class)->findOneBy(['uri' => self::SECRET_OBJECT_URI]); return $object?->getCalendarData(); } public function testUnauthorized(): void { $client = static::requestDavClient('GET', '/dav/'); $this->assertResponseStatusCodeSame(401); } public function testEmptyPasswordIsRejected(): void { $client = static::createClient(); static::requestDav($client, 'GET', '/dav/', 'test_user:'); $this->assertResponseStatusCodeSame(401); } public function testAuthenticatedUserCanReadOwnCalendarObject(): void { $client = static::createClient(); $this->createSecretCalendarObject(); static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH, 'test_user:password'); $this->assertResponseStatusCodeSame(200); $this->assertStringContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); } public function testAnonymousCannotReadCalendarObject(): void { $client = static::createClient(); $this->createSecretCalendarObject(); static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH); $this->assertResponseStatusCodeSame(401); $this->assertStringNotContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); } /** * `sabreAction=asset` is only meaningful for the browser plugin's own assets: it must not * waive the privilege checks on an arbitrary path. */ public function testAssetQueryParameterDoesNotBypassReadAcl(): void { $client = static::createClient(); $this->createSecretCalendarObject(); static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH.'?sabreAction=asset'); $this->assertResponseStatusCodeSame(401); $this->assertStringNotContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); } /** * For an authenticated user, sabre does not throw while collecting the GET headers, * so without the fix the object of another user is streamed back. */ public function testAssetQueryParameterDoesNotBypassReadAclForOtherUsers(): void { $client = static::createClient(); $this->createSecretCalendarObject(); static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH.'?sabreAction=asset', 'test_user2:password2'); $this->assertResponseStatusCodeSame(403); $this->assertStringNotContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); } public function testAssetQueryParameterDoesNotBypassWriteAcl(): void { $client = static::createClient(); $this->createSecretCalendarObject(); static::requestDav($client, 'PUT', self::SECRET_OBJECT_PATH.'?sabreAction=asset'); $this->assertResponseStatusCodeSame(401); $this->assertSame(self::SECRET_CALENDAR_DATA, $this->getSecretCalendarData(), 'The calendar object must not have been modified'); } public function testAssetQueryParameterDoesNotBypassAclOnCollections(): void { $client = static::createClient(); static::requestDav($client, 'GET', '/dav/calendars/test_user/default/?sabreAction=asset&assetName=favicon.ico'); $this->assertResponseStatusCodeSame(401); } /** * The only legitimate use of the bypass: the browser plugin loads its assets from the * server root, and they must be reachable anonymously for public calendar pages. */ public function testBrowserAssetsAreServedAnonymouslyFromRoot(): void { $client = static::createClient(); static::requestDav($client, 'GET', '/dav/?sabreAction=asset&assetName=favicon.ico'); $this->assertResponseStatusCodeSame(200); $this->assertSame( file_get_contents(\dirname(__DIR__, 2).'/vendor/sabre/dav/lib/DAV/Browser/assets/favicon.ico'), $client->getInternalResponse()->getContent() ); } /** * "Public" means everyone: being signed in as another account must not make a public calendar * less readable than it is to a stranger. */ public function testAPublicCalendarIsReadableByAnyoneIncludingSignedInUsers(): void { $client = static::createClient(); $this->createSecretCalendarObject(); /** @var EntityManagerInterface $em */ $em = static::getContainer()->get('doctrine.orm.entity_manager'); $instance = $em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => Principal::PREFIX.'test_user', 'uri' => 'default', ]); $this->assertNotNull($instance); $instance->setPublic(true); $em->flush(); $em->clear(); // Another account, signed in static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH, 'test_user2:password2'); $this->assertResponseStatusCodeSame(200, 'A signed-in user must be able to read a public calendar'); $this->assertStringContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); // And anonymously, which already worked static::requestDav($client, 'GET', self::SECRET_OBJECT_PATH); $this->assertResponseStatusCodeSame(200, 'An anonymous visitor must be able to read a public calendar'); $this->assertStringContainsString(self::SECRET_SUMMARY, $client->getInternalResponse()->getContent()); } /** * Making a calendar public grants reading, never writing. */ public function testAPublicCalendarIsNotWritableByOtherUsers(): void { $client = static::createClient(); $this->createSecretCalendarObject(); $em = static::getContainer()->get('doctrine.orm.entity_manager'); $instance = $em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => Principal::PREFIX.'test_user', 'uri' => 'default', ]); $this->assertNotNull($instance); $instance->setPublic(true); $em->flush(); $em->clear(); static::requestDav($client, 'DELETE', self::SECRET_OBJECT_PATH, 'test_user2:password2'); $this->assertResponseStatusCodeSame(403); $this->assertSame(self::SECRET_CALENDAR_DATA, $this->getSecretCalendarData(), 'The object must still be there'); } public function testWellKnownUrlsRedirectToTheDavEndpoint(): void { $client = static::createClient(); foreach (['/.well-known/caldav', '/.well-known/carddav'] as $wellKnown) { $client->request('GET', $wellKnown); $this->assertResponseStatusCodeSame(301, $wellKnown.' should redirect'); $this->assertResponseRedirects('/dav/'); } } /** * The advertised methods depend on the node: MKCALENDAR only exists inside a calendar home, * so OPTIONS has to answer for the path it was asked about rather than for the root. */ public function testOptionsDescribesTheRequestedPath(): void { $client = static::createClient(); static::requestDav($client, 'OPTIONS', '/dav/'); $this->assertResponseIsSuccessful(); $this->assertStringNotContainsString('MKCALENDAR', (string) $client->getResponse()->headers->get('Allow')); static::requestDav($client, 'OPTIONS', '/dav/calendars/test_user/new-calendar'); $this->assertResponseIsSuccessful(); $this->assertStringContainsString('MKCALENDAR', (string) $client->getResponse()->headers->get('Allow')); } public function testOptionsOnAnUnresolvablePathStillAnswers(): void { $client = static::createClient(); static::requestDav($client, 'OPTIONS', '/dav/calendars/nope/nope/nope'); $this->assertResponseIsSuccessful(); $this->assertStringContainsString('PROPFIND', (string) $client->getResponse()->headers->get('Allow')); } /** * The exact sabre/dav version appeared in the `X-Sabre-Version` header, in every error * body and in the HTML browser, which only helps match an install against known advisories. */ public function testTheSabreVersionIsNotAdvertised(): void { $client = static::requestDavClient('GET', '/dav/'); $this->assertResponseStatusCodeSame(401); $this->assertStringNotContainsString('sabredav-version', $client->getInternalResponse()->getContent()); $this->assertFalse($client->getResponse()->headers->has('X-Sabre-Version')); $this->assertFalse(\Sabre\DAV\Server::$exposeVersion, 'The DAV server must be built with version exposure off'); } }