diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index b07158842d56b..242f3a6b6bd9a 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2325,7 +2325,7 @@ public function search( } $calendarObjects = array_map(function ($o) use ($options) { - $calendarData = Reader::read($o['calendardata']); + $calendarData = Reader::read($o['calendardata'], $this->getReaderOptions()); // Expand recurrences if an explicit time range is requested if ($calendarData instanceof VCalendar @@ -3392,7 +3392,7 @@ public function restoreChanges(int $calendarId, int $calendarType = self::CALEND * @return array */ public function getDenormalizedData(string $calendarData): array { - $vObject = Reader::read($calendarData); + $vObject = Reader::read($calendarData, $this->getReaderOptions()); $vEvents = []; $componentType = null; $component = null; @@ -3843,7 +3843,13 @@ public function moveCalendar($uriName, $uriOrigin, $uriDestination, $newUriName * @return VCalendar */ protected function readCalendarData($objectData) { - return Reader::read($objectData); + return Reader::read($objectData, $this->getReaderOptions()); + } + + private function getReaderOptions(): int { + return $this->config->getSystemValueBool('dav.forgiving_ical_parser', false) + ? Reader::OPTION_FORGIVING + : 0; } /** diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php index 579fb4fe2ade9..adb40f27c27a3 100644 --- a/apps/dav/lib/CalDAV/Import/ImportService.php +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -14,6 +14,7 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarImpl; use OCP\Calendar\CalendarImportOptions; +use OCP\IConfig; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Node; use Sabre\VObject\Reader; @@ -26,6 +27,7 @@ class ImportService { public function __construct( private CalDavBackend $backend, + private IConfig $config, ) { } @@ -83,7 +85,7 @@ public function importText($source): Generator { foreach ($structure['VTIMEZONE'] as $tid => $collection) { $instance = $collection[0]; $sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]); - $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix, $this->getReaderOptions()); $timezones[$tid] = clone $vObject->VTIMEZONE; } // calendar components @@ -98,7 +100,7 @@ public function importText($source): Generator { $sObjectContents .= $importer->extract($instance[2], $instance[3]); } /** @var VCalendar $vObject */ - $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix, $this->getReaderOptions()); // add time zones to object foreach ($this->findTimeZones($vObject) as $zone) { if (isset($timezones[$zone])) { @@ -342,4 +344,10 @@ private function componentValidate(VCalendar $vObject, bool $repair, int $level) return $result; } + + private function getReaderOptions(): int { + return $this->config->getSystemValueBool('dav.forgiving_ical_parser', false) + ? Reader::OPTION_FORGIVING + : 0; + } } diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php index d950669d61fd9..fe398adf1bd79 100644 --- a/apps/dav/lib/CalDAV/Plugin.php +++ b/apps/dav/lib/CalDAV/Plugin.php @@ -8,9 +8,20 @@ namespace OCA\DAV\CalDAV; +use OCP\IConfig; +use Sabre\CalDAV\Exception\InvalidComponentType; +use Sabre\DAV; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\Uri; +use Sabre\VObject; + class Plugin extends \Sabre\CalDAV\Plugin { public const SYSTEM_CALENDAR_ROOT = 'system-calendars'; + public function __construct(private ?IConfig $config = null) { + } + /** * Returns the path to a principal's calendar home. * @@ -36,4 +47,119 @@ public function getCalendarHomeForPrincipal($principalUrl) { return self::SYSTEM_CALENDAR_ROOT . '/calendar-rooms/' . $principalId; } } + + #[\Override] + protected function validateICalendar(&$data, $path, &$modified, RequestInterface $request, ResponseInterface $response, $isNew) { + if (is_resource($data)) { + $data = stream_get_contents($data); + } + + $before = $data; + + try { + if ('[' === substr($data, 0, 1)) { + $vobj = VObject\Reader::readJson($data); + $data = $vobj->serialize(); + $modified = true; + } else { + $vobj = VObject\Reader::read($data, $this->getReaderOptions()); + } + } catch (VObject\ParseException $e) { + throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); + } + + if ('VCALENDAR' !== $vobj->name) { + throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); + } + + $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + + list($parentPath) = Uri\split($path); + $calendarProperties = $this->server->getProperties($parentPath, [$sCCS]); + + if (isset($calendarProperties[$sCCS])) { + $supportedComponents = $calendarProperties[$sCCS]->getValue(); + } else { + $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; + } + + $foundType = null; + + foreach ($vobj->getComponents() as $component) { + switch ($component->name) { + case 'VTIMEZONE': + continue 2; + case 'VEVENT': + case 'VTODO': + case 'VJOURNAL': + $foundType = $component->name; + break; + } + } + + if (!$foundType || !in_array($foundType, $supportedComponents)) { + throw new InvalidComponentType('iCalendar objects must at least have a component of type ' . implode(', ', $supportedComponents)); + } + + $options = VObject\Node::PROFILE_CALDAV; + $prefer = $this->server->getHTTPPrefer(); + + if ('strict' !== $prefer['handling']) { + $options |= VObject\Node::REPAIR; + } + + $messages = $vobj->validate($options); + + $highestLevel = 0; + $warningMessage = null; + + foreach ($messages as $message) { + if ($message['level'] > $highestLevel) { + $highestLevel = $message['level']; + $warningMessage = $message['message']; + } + switch ($message['level']) { + case 1: + $modified = true; + break; + case 2: + break; + case 3: + throw new DAV\Exception\UnsupportedMediaType('Validation error in iCalendar: ' . $message['message']); + } + } + + if ($warningMessage) { + $response->setHeader('X-Sabre-Ew-Gross', 'iCalendar validation warning: ' . $warningMessage); + } + + $subModified = false; + + $this->server->emit( + 'calendarObjectChange', + [ + $request, + $response, + $vobj, + $parentPath, + &$subModified, + $isNew, + ] + ); + + if ($modified || $subModified) { + $data = $vobj->serialize(); + if (!$modified && 0 !== strcmp($data, $before)) { + $modified = true; + } + } + + $vobj->destroy(); + } + + private function getReaderOptions(): int { + return $this->config?->getSystemValueBool('dav.forgiving_ical_parser', false) + ? VObject\Reader::OPTION_FORGIVING + : 0; + } } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 68068dcfb6302..bc53a2deb9207 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -205,7 +205,7 @@ public function __construct( // calendar plugins if ($this->requestIsForSubtree(['calendars', 'public-calendars', 'system-calendars', 'principals'])) { $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(RateLimiting::class))); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin(\OCP\Server::get(IConfig::class))); $this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger)); $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class))); diff --git a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php index dc59eb82205e9..09b0cba9b634a 100644 --- a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php +++ b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php @@ -52,7 +52,7 @@ abstract class AbstractCalDavBackend extends TestCase { protected IGroupManager&MockObject $groupManager; protected IEventDispatcher&MockObject $dispatcher; private LoggerInterface&MockObject $logger; - private IConfig&MockObject $config; + protected IConfig&MockObject $config; private ISecureRandom $random; protected SharingBackend $sharingBackend; protected IDBConnection $db; diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index a547eca0cabc4..e65aa713ff9f1 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -2104,4 +2104,24 @@ public function testDefaultAlarmProperties(): void { // Clean up $this->backend->deleteCalendar($calendars[0]['id'], true); } + + private const ICS_ENTOURAGE = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nDTSTART:20260715T100000Z\r\nDTEND:20260715T110000Z\r\nSUMMARY:Test\r\nUID:test-uid-123\r\nX-ENTOURAGE_UUID:test-uid-123\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + public function testGetDenormalizedDataRejectsNonStandardPropertyWhenFlagDisabled(): void { + $this->config->method('getSystemValueBool') + ->with('dav.forgiving_ical_parser', false) + ->willReturn(false); + + $this->expectException(\Sabre\VObject\ParseException::class); + $this->backend->getDenormalizedData(self::ICS_ENTOURAGE); + } + + public function testGetDenormalizedDataAcceptsNonStandardPropertyWhenFlagEnabled(): void { + $this->config->method('getSystemValueBool') + ->with('dav.forgiving_ical_parser', false) + ->willReturn(true); + + $result = $this->backend->getDenormalizedData(self::ICS_ENTOURAGE); + $this->assertSame('test-uid-123', $result['uid']); + } } diff --git a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php index 27db3ff9d8452..cd584c99762c8 100644 --- a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -11,22 +11,29 @@ use OCA\DAV\CalDAV\CalendarImpl; use OCA\DAV\CalDAV\Import\ImportService; use OCP\Calendar\CalendarImportOptions; +use OCP\IConfig; use PHPUnit\Framework\MockObject\MockObject; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; +use Sabre\VObject\ParseException; class ImportServiceTest extends \Test\TestCase { private ImportService $service; private CalendarImpl|MockObject $calendar; private CalDavBackend|MockObject $backend; + private IConfig|MockObject $config; private array $importResults = []; protected function setUp(): void { parent::setUp(); $this->backend = $this->createMock(CalDavBackend::class); - $this->service = new ImportService($this->backend); + $this->config = $this->createMock(IConfig::class); + $this->config->method('getSystemValueBool') + ->with('dav.forgiving_ical_parser', false) + ->willReturn(false); + $this->service = new ImportService($this->backend, $this->config); $this->calendar = $this->createMock(CalendarImpl::class); } @@ -148,4 +155,30 @@ public function testImportWithMultiLineUID(): void { $this->assertArrayHasKey($longUID, $result); $this->assertEquals('created', $result[$longUID]['outcome']); } + + private const ICS_ENTOURAGE = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nDTSTART:20260715T100000Z\r\nDTEND:20260715T110000Z\r\nSUMMARY:Test\r\nUID:test-uid-123\r\nX-ENTOURAGE_UUID:test-uid-123\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + public function testImportTextRejectsNonStandardPropertyWhenFlagDisabled(): void { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, self::ICS_ENTOURAGE); + rewind($stream); + + $this->expectException(ParseException::class); + iterator_to_array($this->service->importText($stream)); + } + + public function testImportTextAcceptsNonStandardPropertyWhenFlagEnabled(): void { + $this->config = $this->createMock(IConfig::class); + $this->config->method('getSystemValueBool') + ->with('dav.forgiving_ical_parser', false) + ->willReturn(true); + $this->service = new ImportService($this->backend, $this->config); + + $stream = fopen('php://memory', 'r+'); + fwrite($stream, self::ICS_ENTOURAGE); + rewind($stream); + + $objects = iterator_to_array($this->service->importText($stream)); + $this->assertCount(1, $objects); + } } diff --git a/apps/dav/tests/unit/CalDAV/PluginTest.php b/apps/dav/tests/unit/CalDAV/PluginTest.php index 00c80e178585b..96611bbd83137 100644 --- a/apps/dav/tests/unit/CalDAV/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/PluginTest.php @@ -9,6 +9,12 @@ namespace OCA\DAV\Tests\unit\CalDAV; use OCA\DAV\CalDAV\Plugin; +use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; +use Sabre\DAV\Exception\UnsupportedMediaType; +use Sabre\DAV\Server as SabreServer; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; use Test\TestCase; class PluginTest extends TestCase { @@ -45,4 +51,56 @@ public function testGetCalendarHomeForPrincipal(string $input, string $expected) public function testGetCalendarHomeForUnknownPrincipal(): void { $this->assertNull($this->plugin->getCalendarHomeForPrincipal('FOO/BAR/BLUB')); } + + private const ICS_ENTOURAGE = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nDTSTART:20260715T100000Z\r\nDTEND:20260715T110000Z\r\nSUMMARY:Test\r\nUID:test-uid-123\r\nX-ENTOURAGE_UUID:test-uid-123\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + private function makePlugin(bool $forgiving): Plugin { + /** @var IConfig&MockObject $config */ + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool') + ->with('dav.forgiving_ical_parser', false) + ->willReturn($forgiving); + + $plugin = new class($config) extends Plugin { + public function exposeValidateICalendar(string &$data, string $path, bool &$modified, RequestInterface $request, ResponseInterface $response, bool $isNew): void { + $this->validateICalendar($data, $path, $modified, $request, $response, $isNew); + } + }; + + /** @var SabreServer&MockObject $server */ + $server = $this->createMock(SabreServer::class); + $server->method('getProperties')->willReturn([]); + $server->method('getHTTPPrefer')->willReturn(['handling' => 'lenient']); + $server->method('emit')->willReturn(true); + + // Inject server without calling initialize() to avoid its side effects on xml/resourceTypeMapping + $prop = new \ReflectionProperty(\Sabre\CalDAV\Plugin::class, 'server'); + $prop->setValue($plugin, $server); + + return $plugin; + } + + public function testValidateICalendarRejectsNonStandardPropertyWhenFlagDisabled(): void { + $plugin = $this->makePlugin(false); + $data = self::ICS_ENTOURAGE; + $modified = false; + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $this->expectException(UnsupportedMediaType::class); + $plugin->exposeValidateICalendar($data, 'calendars/admin/personal/test.ics', $modified, $request, $response, true); + } + + public function testValidateICalendarAcceptsNonStandardPropertyWhenFlagEnabled(): void { + $plugin = $this->makePlugin(true); + $data = self::ICS_ENTOURAGE; + $modified = false; + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once()) + ->method('setHeader') + ->with('X-Sabre-Ew-Gross', $this->stringContains('X-ENTOURAGE_UUID')); + + $plugin->exposeValidateICalendar($data, 'calendars/admin/personal/test.ics', $modified, $request, $response, true); + } } diff --git a/config/config.sample.php b/config/config.sample.php index 55ceb784df971..783bd2ee91f5e 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -387,6 +387,16 @@ */ 'carddav_sync_request_truncation' => 2500, + /** + * Enable ``Sabre\VObject\Reader::OPTION_FORGIVING`` for CalDAV parsing. + * When set to ``true``, the iCalendar parser accepts property names that do + * not conform to RFC 5545 ยง3.2, such as ``X-ENTOURAGE_UUID`` produced by + * Outlook for Mac (underscores are invalid in property names). + * + * Defaults to ``false``. + */ + 'dav.forgiving_ical_parser' => false, + /** * ``true`` enables a relaxed session timeout, where the session timeout would no longer be * handled by Nextcloud but by either the PHP garbage collection or the expiration of