diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 9eab04561595d..b1a76446638fc 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -229,6 +229,7 @@ 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php', 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => $baseDir . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index e9a0ef01c0778..c12cd7c55f943 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -244,6 +244,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php', 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', diff --git a/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php new file mode 100644 index 0000000000000..ec1ad1fee9fca --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php @@ -0,0 +1,129 @@ +server = $server; + + // Run right after the ACL plugin to make sure that the current user principal is available + $server->on('beforeMethod:PUT', $this->beforePut(...), 21); + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): void { + $userAgent = $request->getHeader('User-Agent'); + if (!$userAgent || !$this->isThunderbirdUserAgent($userAgent)) { + return; + } + + if (!str_starts_with($request->getPath(), 'calendars/')) { + return; + } + + if (!str_contains($request->getHeader('Content-Type') ?? '', 'text/calendar')) { + return; + } + + $currentUserPrincipal = $this->getCurrentUserPrincipal(); + if ($currentUserPrincipal === null) { + return; + } + + // Need to set the body again here so that other handlers are able to read it afterward + $requestBody = $request->getBodyAsString(); + $request->setBody($requestBody); + + try { + $vCalendar = VObjectReader::read($requestBody); + } catch (\Throwable $e) { + return; + } + if (!($vCalendar instanceof VCalendar)) { + return; + } + + /** @var string|null $uid */ + $uid = $vCalendar->getBaseComponent('VEVENT')?->UID?->getValue(); + if ($uid === null) { + return; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('co.uri') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $qb->expr()->eq('co.calendarid', 'c.id')) + ->where( + $qb->expr()->eq( + 'c.principaluri', + $qb->createNamedParameter($currentUserPrincipal, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR, + ), + $qb->expr()->eq( + 'co.uid', + $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR, + ), + ); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if (count($rows) !== 1) { + // Either no collision or too many collisions + return; + } + + $requestUrl = $request->getUrl(); + [$prefix] = \Sabre\Uri\split($requestUrl); + $objectUri = $rows[0]['uri']; + $request->setUrl("$prefix/$objectUri"); + } + + private function isThunderbirdUserAgent(string $userAgent): bool { + return str_contains($userAgent, 'Thunderbird/'); + } + + private function getCurrentUserPrincipal(): ?string { + /** @var \Sabre\DAV\Auth\Plugin $authPlugin */ + $authPlugin = $this->server?->getPlugin('auth'); + return $authPlugin?->getCurrentPrincipal(); + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 9b4a1b3d33c9c..f1079201482aa 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -51,6 +51,7 @@ use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; use OCA\DAV\Connector\Sabre\SharesPlugin; use OCA\DAV\Connector\Sabre\TagsPlugin; +use OCA\DAV\Connector\Sabre\ThunderbirdPutInvitationQuirkPlugin; use OCA\DAV\Connector\Sabre\ZipFolderPlugin; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\PublicAuth; @@ -130,6 +131,9 @@ public function __construct( $this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); $this->server->addPlugin(new AppleQuirksPlugin()); + $this->server->addPlugin(new ThunderbirdPutInvitationQuirkPlugin( + \OCP\Server::get(IDBConnection::class), + )); // Backends $authBackend = new Auth( diff --git a/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php new file mode 100644 index 0000000000000..153e8b157a2dc --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php @@ -0,0 +1,437 @@ +server = $this->createMock(Server::class); + $this->db = $this->createMock(IDBConnection::class); + + $this->plugin = new ThunderbirdPutInvitationQuirkPlugin( + $this->db, + ); + } + + public function testInitialize(): void { + $this->server->expects(self::once()) + ->method('on') + ->with('beforeMethod:PUT', $this->plugin->beforePut(...), 21); + + $this->plugin->initialize($this->server); + } + + public static function provideBeforePutData(): array { + return [ + // No collision + [[], false], + // Many collisions + [ + [ + ['uri' => 'sabredav-3dd349f8-58e0-483d-921f-70bc9f02366b.ics'], + ['uri' => 'sabredav-19a50615-2db0-4046-a537-000979925e16.ics'], + ], + false, + ], + // Exactly one collision + [ + [ + ['uri' => 'sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'], + ], + true, + ], + ]; + } + + #[DataProvider('provideBeforePutData')] + public function testBeforePut(array $rows, bool $expectUrlChange): void { + $ics = <<createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + $request->expects(self::once()) + ->method('setBody') + ->with($ics); + if ($expectUrlChange) { + $request->expects(self::once()) + ->method('getUrl') + ->willReturn('remote.php/dav/calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('setUrl') + ->with('remote.php/dav/calendars/usera/personal/sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'); + } else { + $request->expects(self::never()) + ->method('getUrl'); + $request->expects(self::never()) + ->method('setUrl'); + } + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select') + ->willReturnSelf(); + $qb->method('from') + ->willReturnSelf(); + $qb->method('join') + ->willReturnSelf(); + $qb->method('where') + ->willReturnSelf(); + $expr = $this->createMock(IExpressionBuilder::class); + $qb->method('expr') + ->willReturn($expr); + $this->db->expects(self::once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $result = $this->createMock(IResult::class); + $result->expects(self::once()) + ->method('fetchAll') + ->willReturn($rows); + $result->expects(self::once()) + ->method('closeCursor'); + $qb->expects(self::once()) + ->method('executeQuery') + ->willReturn($result); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithInvalidUserAgent(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getHeader') + ->with('User-Agent') + ->willReturn('curl/8.14.1'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithUnrelatedRequestPath(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getHeader') + ->with('User-Agent') + ->willReturn('Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('foo/bar/baz'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithInvalidContentType(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'foo/bar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithoutCurrentUserPrincipal(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn(null); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithoutAuthPlugin(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn(null); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public static function provideInvalidIcsData(): array { + $noUid = <<createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + $request->expects(self::once()) + ->method('setBody') + ->with($ics); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } +}