Skip to content

Commit 92afc36

Browse files
authored
Merge pull request #1393 from nextcloud/enh/ai-safety-api-key
enh: Require API-Key for webDAV API
2 parents eb0541f + 834da9e commit 92afc36

File tree

5 files changed

+96
-3
lines changed

5 files changed

+96
-3
lines changed

appinfo/info.xml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,5 @@ The app does not send any sensitive data to cloud providers or similar services.
132132
<collections>
133133
<collection>OCA\Recognize\Dav\RootCollection</collection>
134134
</collections>
135-
<plugins>
136-
<plugin>OCA\Recognize\Dav\Faces\PropFindPlugin</plugin>
137-
</plugins>
138135
</sabre>
139136
</info>

lib/AppInfo/Application.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace OCA\Recognize\AppInfo;
99

1010
use OCA\DAV\Connector\Sabre\Principal;
11+
use OCA\Recognize\Dav\Faces\PropFindPlugin;
1112
use OCA\Recognize\Hooks\FileListener;
1213
use OCP\AppFramework\App;
1314
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -21,6 +22,7 @@
2122
use OCP\Files\Events\Node\NodeDeletedEvent;
2223
use OCP\Files\Events\Node\NodeRenamedEvent;
2324
use OCP\Files\Events\NodeRemovedFromCache;
25+
use OCP\SabrePluginEvent;
2426
use OCP\Share\Events\ShareCreatedEvent;
2527
use OCP\Share\Events\ShareDeletedEvent;
2628

@@ -65,5 +67,16 @@ public function register(IRegistrationContext $context): void {
6567
* @throws \Throwable
6668
*/
6769
public function boot(IBootContext $context): void {
70+
$eventDispatcher = \OCP\Server::get(IEventDispatcher::class);
71+
$eventDispatcher->addListener('OCA\DAV\Connector\Sabre::addPlugin', function (SabrePluginEvent $event): void {
72+
$server = $event->getServer();
73+
74+
if ($server !== null) {
75+
// We have to register the PropFindPlugin here and not info.xml,
76+
// because info.xml plugins are loaded, after the
77+
// beforeMethod:* hook has already been emitted.
78+
$server->addPlugin($this->getContainer()->get(PropFindPlugin::class));
79+
}
80+
});
6881
}
6982
}

lib/Dav/Faces/PropFindPlugin.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@
1515
use OCA\Recognize\Db\FaceDetectionWithTitle;
1616
use OCP\AppFramework\Db\DoesNotExistException;
1717
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
18+
use OCP\AppFramework\Utility\ITimeFactory;
1819
use OCP\DB\Exception;
1920
use OCP\Files\DavUtil;
2021
use OCP\IPreview;
22+
use OCP\Security\ICrypto;
23+
use Psr\Log\LoggerInterface;
2124
use Sabre\DAV\Exception\Forbidden;
2225
use Sabre\DAV\INode;
2326
use Sabre\DAV\PropFind;
2427
use Sabre\DAV\Server;
2528
use Sabre\DAV\ServerPlugin;
29+
use Sabre\HTTP\RequestInterface;
30+
use Sabre\HTTP\ResponseInterface;
2631

2732
final class PropFindPlugin extends ServerPlugin {
2833
public const FACE_DETECTIONS_PROPERTYNAME = '{http://nextcloud.org/ns}face-detections';
@@ -31,12 +36,17 @@ final class PropFindPlugin extends ServerPlugin {
3136
public const NBITEMS_PROPERTYNAME = '{http://nextcloud.org/ns}nbItems';
3237
public const FACE_PREVIEW_IMAGE_PROPERTYNAME = '{http://nextcloud.org/ns}face-preview-image';
3338

39+
public const API_KEY_TIMEOUT = 60 * 60 * 24;
40+
3441
private Server $server;
3542

3643
public function __construct(
3744
private FaceDetectionMapper $faceDetectionMapper,
3845
private IPreview $previewManager,
3946
private FaceClusterMapper $faceClusterMapper,
47+
private ICrypto $crypto,
48+
private LoggerInterface $logger,
49+
private ITimeFactory $timeFactory,
4050
) {
4151
}
4252

@@ -45,6 +55,7 @@ public function initialize(Server $server) {
4555

4656
$this->server->on('propFind', [$this, 'propFind']);
4757
$this->server->on('beforeMove', [$this, 'beforeMove']);
58+
$this->server->on('beforeMethod:*', [$this, 'beforeMethod'], 1);
4859
}
4960

5061

@@ -113,4 +124,38 @@ public function beforeMove($source, $target) {
113124
}
114125
return true;
115126
}
127+
128+
public function beforeMethod(RequestInterface $request, ResponseInterface $response) {
129+
if (!str_starts_with($request->getPath(), 'recognize')) {
130+
return;
131+
}
132+
$key = $request->getHeader('X-Recognize-Api-Key');
133+
if ($key === null) {
134+
throw new Forbidden('You must provide a valid X-Recognize-Api-Key');
135+
}
136+
try {
137+
$json = $this->crypto->decrypt($key);
138+
} catch (\Exception $e) {
139+
$this->logger->warning('Failed to decrypt recognize API key. Denying entry.', ['exception' => $e]);
140+
throw new Forbidden('You must provide a valid X-Recognize-Api-Key');
141+
}
142+
try {
143+
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
144+
} catch (\JsonException $e) {
145+
$this->logger->warning('Failed to decode recognize API key. Denying entry.', ['exception' => $e]);
146+
throw new Forbidden('You must provide a valid X-Recognize-Api-Key');
147+
}
148+
149+
if (!isset($data['type']) || $data['type'] !== 'recognize-api-key' || !isset($data['version']) || $data['version'] !== 1 || !isset($data['timestamp'])) {
150+
$this->logger->warning('Failed to validate recognize API key.', ['data' => $data]);
151+
throw new Forbidden('You must provide a valid X-Recognize-Api-Key');
152+
}
153+
154+
if ($this->timeFactory->now()->getTimestamp() - (int)$data['timestamp'] < self::API_KEY_TIMEOUT) {
155+
return;
156+
}
157+
158+
$this->logger->info('API key is too old, denying entry', ['data' => $data]);
159+
throw new Forbidden('You must provide a valid X-Recognize-Api-Key');
160+
}
116161
}

lib/Public/ApiKeyManager.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) 2025 The Recognize contributors.
5+
* This file is licensed under the Affero General Public License version 3 or later. See the COPYING file.
6+
*/
7+
8+
namespace OCA\Recognize\Public;
9+
10+
use OCP\AppFramework\Utility\ITimeFactory;
11+
use OCP\Security\ICrypto;
12+
13+
/**
14+
* @api
15+
*/
16+
class ApiKeyManager {
17+
18+
public function __construct(
19+
private ICrypto $crypto,
20+
private ITimeFactory $timeFactory,
21+
) {
22+
}
23+
24+
/**
25+
* @throws \JsonException
26+
*/
27+
public function generateApiKey(): string {
28+
return $this->crypto->encrypt(json_encode(['type' => 'recognize-api-key', 'version' => 1, 'timestamp' => $this->timeFactory->now()->getTimestamp()], JSON_THROW_ON_ERROR));
29+
}
30+
}

psalm-baseline.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@
44
<DeprecatedInterface>
55
<code><![CDATA[$this->getContainer()]]></code>
66
</DeprecatedInterface>
7+
<MissingDependency>
8+
<code><![CDATA[PropFindPlugin]]></code>
9+
</MissingDependency>
710
<MixedArgument>
811
<code><![CDATA[Principal::class]]></code>
12+
<code><![CDATA[PropFindPlugin::class]]></code>
913
</MixedArgument>
1014
<UndefinedClass>
1115
<code><![CDATA[Principal]]></code>
1216
</UndefinedClass>
17+
<UndefinedDocblockClass>
18+
<code><![CDATA[$event->getServer()]]></code>
19+
<code><![CDATA[$server]]></code>
20+
</UndefinedDocblockClass>
1321
</file>
1422
<file src="lib/BackgroundJobs/ClassifierJob.php">
1523
<ArgumentTypeCoercion>

0 commit comments

Comments
 (0)