Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion apps/dav/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<name>WebDAV</name>
<summary>WebDAV endpoint</summary>
<description>WebDAV endpoint</description>
<version>3.0.0-dev.1</version>
<version>3.0.0-dev.2</version>
<licence>agpl</licence>
<author>owncloud.org</author>
<namespace>DAV</namespace>
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php',
'OCA\\DAV\\Migration\\Version3000Date20261019000000' => $baseDir . '/../lib/Migration/Version3000Date20261019000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php',
'OCA\\DAV\\Migration\\Version3000Date20261019000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20261019000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',
Expand Down
69 changes: 67 additions & 2 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Server;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Sabre\CalDAV\Backend\AbstractBackend;
Expand Down Expand Up @@ -2976,6 +2978,7 @@ public function getSubscriptionsForUser($principalUri) {
$fields[] = 'principaluri';
$fields[] = 'lastmodified';
$fields[] = 'synctoken';
$fields[] = 'credentials';

$query = $this->db->getQueryBuilder();
$query->select($fields)
Expand All @@ -2991,6 +2994,7 @@ public function getSubscriptionsForUser($principalUri) {
'uri' => $row['uri'],
'principaluri' => $row['principaluri'],
'source' => $row['source'],
'credentials' => $this->decryptSubscriptionCredentials($row['id'], $row['credentials']),
'lastmodified' => $row['lastmodified'],

'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
Expand Down Expand Up @@ -3020,10 +3024,12 @@ public function createSubscription($principalUri, $uri, array $properties) {
throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions');
}

[$source, $credentials] = self::extractSubscriptionCredentials($properties['{http://calendarserver.org/ns/}source']->getHref());
$values = [
'principaluri' => $principalUri,
'uri' => $uri,
'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(),
'source' => $source,
'credentials' => $credentials,
'lastmodified' => time(),
];

Expand Down Expand Up @@ -3085,14 +3091,22 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) {

foreach ($mutations as $propertyName => $propertyValue) {
if ($propertyName === '{http://calendarserver.org/ns/}source') {
$newValues['source'] = $propertyValue->getHref();
[$newValues['source'], $newValues['credentials']] = self::extractSubscriptionCredentials($propertyValue->getHref());
} else {
$fieldName = $this->subscriptionPropertyMap[$propertyName][0];
$newValues[$fieldName] = $propertyValue;
}
}

$subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) {
// clients only see the source without credentials, so sending it back unchanged keeps them
if (array_key_exists('credentials', $newValues) && $newValues['credentials'] === null) {
$currentSource = $this->getSubscriptionById($subscriptionId)['source'] ?? null;
if ($currentSource === $newValues['source']) {
unset($newValues['credentials']);
}
}

$query = $this->db->getQueryBuilder();
$query->update('calendarsubscriptions')
->set('lastmodified', $query->createNamedParameter(time()));
Expand All @@ -3111,6 +3125,57 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) {
});
}

private static function encryptSubscriptionCredentials(string $username, string $password): string {
return Server::get(ICrypto::class)->encrypt(json_encode([
'username' => $username,
'password' => $password,
], JSON_THROW_ON_ERROR));
}

/**
* @return array{0: string, 1: string}|null username and password
*/
private function decryptSubscriptionCredentials(int|string $subscriptionId, mixed $credentials): ?array {
if (!is_string($credentials) || $credentials === '') {
return null;
}

try {
$decoded = json_decode(Server::get(ICrypto::class)->decrypt($credentials), true, 2, JSON_THROW_ON_ERROR);
} catch (\Exception $e) {
$this->logger->error('Could not decrypt the credentials of subscription ' . $subscriptionId, [
'exception' => $e,
]);
return null;
}

return [(string)$decoded['username'], (string)$decoded['password']];
}

/**
* Splits the user info off a subscription source url
*
* The user info is percent-decoded and encrypted.
*
* @return array{0: string, 1: ?string} the source without user info and the encrypted credentials
*/
public static function extractSubscriptionCredentials(string $source): array {
// the greedy match stops at the last '@' of the authority
if (preg_match('/^([a-z][a-z0-9+.\-]*:\/\/)([^\/?#]*)@/i', $source, $matches) !== 1) {
return [$source, null];
}

$source = $matches[1] . substr($source, strlen($matches[0]));
[$username, $password] = array_pad(explode(':', $matches[2], 2), 2, '');
$username = rawurldecode($username);
$password = rawurldecode($password);
if ($username === '' && $password === '') {
return [$source, null];
}

return [$source, self::encryptSubscriptionCredentials($username, $password)];
}

/**
* Deletes a subscription.
*
Expand Down
6 changes: 2 additions & 4 deletions apps/dav/lib/CalDAV/WebcalCaching/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,8 @@ public function queryWebcalFeed(array $subscription): ?array {
'stream' => true,
];

$user = parse_url($subscription['source'], PHP_URL_USER);
$pass = parse_url($subscription['source'], PHP_URL_PASS);
if ($user !== null && $pass !== null) {
$params[RequestOptions::AUTH] = [$user, $pass];
if (isset($subscription['credentials'])) {
$params[RequestOptions::AUTH] = $subscription['credentials'];
}

try {
Expand Down
76 changes: 76 additions & 0 deletions apps/dav/lib/Migration/Version3000Date20261019000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Migration;

use Closure;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

#[AddColumn(table: 'calendarsubscriptions', name: 'credentials', type: ColumnType::TEXT, description: 'Encrypted credentials extracted from the source url')]
class Version3000Date20261019000000 extends SimpleMigrationStep {
public function __construct(
private readonly IDBConnection $connection,
) {
}

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->getTable('calendarsubscriptions');
if ($table->hasColumn('credentials')) {
return null;
}

$table->addColumn('credentials', Types::TEXT, [
'notnull' => false,
]);

return $schema;
}

#[Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$select = $this->connection->getQueryBuilder();
$select->select('id', 'source')
->from('calendarsubscriptions')
->where($select->expr()->like('source', $select->createNamedParameter('%@%')))
->andWhere($select->expr()->isNull('credentials'));
$result = $select->executeQuery();

$update = $this->connection->getQueryBuilder();
$update->update('calendarsubscriptions')
->set('source', $update->createParameter('source'))
->set('credentials', $update->createParameter('credentials'))
->where($update->expr()->eq('id', $update->createParameter('id')));

while ($row = $result->fetchAssociative()) {
Comment thread
tcitworld marked this conversation as resolved.
Resolved
[$source, $credentials] = CalDavBackend::extractSubscriptionCredentials((string)$row['source']);
if ($credentials === null) {
continue;
}

$update->setParameter('source', $source)
->setParameter('credentials', $credentials)
->setParameter('id', (int)$row['id'], IQueryBuilder::PARAM_INT)
->executeStatement();
}
$result->closeCursor();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Tests\unit\CalDAV;

use OCA\DAV\CalDAV\CalDavBackend;
use OCP\Security\ICrypto;
use OCP\Server;
use PHPUnit\Framework\Attributes\DataProvider;
use Test\TestCase;

class CalDavBackendSubscriptionCredentialsTest extends TestCase {
public static function sourceWithCredentialsProvider(): array {
return [
'plain' => ['https://user:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pass'],
'webcal' => ['webcal://user:pass@example.com/cal.ics', 'webcal://example.com/cal.ics', 'user', 'pass'],
'port, query and fragment' => ['https://user:pass@example.com:8443/cal.ics?a=b@c#frag', 'https://example.com:8443/cal.ics?a=b@c#frag', 'user', 'pass'],
'percent-encoded' => ['https://us%40er:p%23ss%3Aw%2Frd%25@example.com/cal.ics', 'https://example.com/cal.ics', 'us@er', 'p#ss:w/rd%'],
'plus is literal' => ['https://user:pa+ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa+ss'],
'utf-8' => ['https://user:p%C3%A4ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'päss'],
'unencoded colon in password' => ['https://user:pa:ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa:ss'],
'unencoded at sign in username' => ['https://user@domain.tld:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user@domain.tld', 'pass'],
'username only' => ['https://user@example.com/cal.ics', 'https://example.com/cal.ics', 'user', ''],
'password only' => ['https://:pass@example.com/cal.ics', 'https://example.com/cal.ics', '', 'pass'],
];
}

#[DataProvider('sourceWithCredentialsProvider')]
public function testExtractWithCredentials(string $source, string $expectedSource, string $expectedUsername, string $expectedPassword): void {
[$strippedSource, $credentials] = CalDavBackend::extractSubscriptionCredentials($source);

$this->assertSame($expectedSource, $strippedSource);
$this->assertIsString($credentials);
$this->assertSame(
['username' => $expectedUsername, 'password' => $expectedPassword],
json_decode(Server::get(ICrypto::class)->decrypt($credentials), true),
);
}

public static function sourceWithoutCredentialsProvider(): array {
return [
'no user info' => ['https://example.com/cal.ics', 'https://example.com/cal.ics'],
'at sign in path' => ['https://example.com/user@domain/cal.ics', 'https://example.com/user@domain/cal.ics'],
'at sign in query' => ['https://example.com/cal.ics?user=a@b', 'https://example.com/cal.ics?user=a@b'],
'empty user info' => ['https://@example.com/cal.ics', 'https://example.com/cal.ics'],
'empty username and password' => ['https://:@example.com/cal.ics', 'https://example.com/cal.ics'],
'no scheme' => ['user:pass@example.com/cal.ics', 'user:pass@example.com/cal.ics'],
];
}

#[DataProvider('sourceWithoutCredentialsProvider')]
public function testExtractWithoutCredentials(string $source, string $expectedSource): void {
$this->assertSame([$expectedSource, null], CalDavBackend::extractSubscriptionCredentials($source));
}
}
56 changes: 56 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,62 @@ public function testSubscriptions(): void {
$this->assertCount(0, $subscriptions);
}

public function testSubscriptionCredentials(): void {
$id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [
'{http://calendarserver.org/ns/}source' => new Href('webcal://us%40er:p%23ss@example.com/cal.ics'),
]);

$this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
$this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id));

// unchanged source without user info keeps the credentials
$patch = new PropPatch([
'{http://calendarserver.org/ns/}source' => new Href('webcal://example.com/cal.ics'),
]);
$this->backend->updateSubscription($id, $patch);
$patch->commit();
$this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id));

$patch = new PropPatch([
'{http://calendarserver.org/ns/}source' => new Href('webcal://other:secret@example.com/cal.ics'),
]);
$this->backend->updateSubscription($id, $patch);
$patch->commit();
$this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
$this->assertSame(['other', 'secret'], $this->getSubscriptionCredentials($id));

// changed source without user info drops the credentials
$patch = new PropPatch([
'{http://calendarserver.org/ns/}source' => new Href('webcal://example.org/cal.ics'),
]);
$this->backend->updateSubscription($id, $patch);
$patch->commit();
$this->assertSame('webcal://example.org/cal.ics', $this->backend->getSubscriptionById($id)['source']);
$this->assertNull($this->getSubscriptionCredentials($id));

$this->backend->deleteSubscription($id);
}

private function getSubscriptionCredentials(int|string $id): ?array {
foreach ($this->backend->getSubscriptionsForUser(self::UNIT_TEST_USER) as $subscription) {
if ((string)$subscription['id'] === (string)$id) {
return $subscription['credentials'];
}
}
$this->fail('Subscription ' . $id . ' not found');
}

public function testSubscriptionWithoutCredentials(): void {
$id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [
'{http://calendarserver.org/ns/}source' => new Href('https://example.com/cal.ics'),
]);

$this->assertSame('https://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
$this->assertNull($this->getSubscriptionCredentials($id));

$this->backend->deleteSubscription($id);
}

public static function providesSchedulingData(): array {
$data = <<<EOS
BEGIN:VCALENDAR
Expand Down
Loading
Loading