Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed, once the data is read the first time with the forgiving flag, it should not need to be read with this flag.


// Expand recurrences if an explicit time range is requested
if ($calendarData instanceof VCalendar
Expand Down Expand Up @@ -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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

$vEvents = [];
$componentType = null;
$component = null;
Expand Down Expand Up @@ -3843,7 +3843,13 @@ public function moveCalendar($uriName, $uriOrigin, $uriDestination, $newUriName
* @return VCalendar
*/
protected function readCalendarData($objectData) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

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;
}

/**
Expand Down
12 changes: 10 additions & 2 deletions apps/dav/lib/CalDAV/Import/ImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ class ImportService {

public function __construct(
private CalDavBackend $backend,
private IConfig $config,
) {
}

Expand Down Expand Up @@ -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
Expand All @@ -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])) {
Expand Down Expand Up @@ -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;
}
}
Comment on lines +348 to 353

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an import option, not a system configuration value

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, that makes sense, ImportOption is a better approach for andling the ImportService code path, but what about the main problem this PR is addressing, i.e., if I get the non-standard ICS from CalDAV PUT which goes through Plugin::validateICalendar?

Should the IConfig be used for that though?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll the issue you linked specifically states that the issue is with importing. Not with general PUT requests from a specific client, so I would not add a plugin to validate the events.

Also there is a PR that will fix a lot of this in the future.

#55178
nextcloud/calendar#8483

126 changes: 126 additions & 0 deletions apps/dav/lib/CalDAV/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
2 changes: 1 addition & 1 deletion apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
}
35 changes: 34 additions & 1 deletion apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}
Expand Down Expand Up @@ -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);
}
}
58 changes: 58 additions & 0 deletions apps/dav/tests/unit/CalDAV/PluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Loading