Skip to content
Merged
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
53 changes: 52 additions & 1 deletion lib/Controller/WopiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use OCA\Richdocuments\Service\FederationService;
use OCA\Richdocuments\Service\SettingsService;
use OCA\Richdocuments\Service\UserScopeService;
use OCA\Richdocuments\Service\WopiRateLimitService;
use OCA\Richdocuments\TaskProcessingManager;
use OCA\Richdocuments\TemplateManager;
use OCA\Richdocuments\TokenManager;
Expand Down Expand Up @@ -59,6 +60,7 @@
use OCP\IUserManager;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use OCP\Security\RateLimiting\IRateLimitExceededException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as IShareManager;
use OCP\Share\IShare;
Expand Down Expand Up @@ -97,6 +99,7 @@ public function __construct(
private SettingsService $settingsService,
private CapabilitiesService $capabilitiesService,
private Helper $helper,
private WopiRateLimitService $wopiRateLimitService,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -132,11 +135,35 @@ public function checkFileInfo(
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

if ($wopi->isGuest()) {
try {
$this->wopiRateLimitService->registerRequest($wopi, 'checkFileInfo');
} catch (IRateLimitExceededException $e) {
$this->logger->warning('WOPI checkFileInfo rate limit exceeded for token {wopiId} from {remoteAddress}', [
'wopiId' => $wopi->getId(),
'remoteAddress' => $this->request->getRemoteAddress(),
]);
return new JSONResponse([], Http::STATUS_TOO_MANY_REQUESTS);
}
}

$isPublic = empty($wopi->getEditorUid());
$isVersion = $version !== '0';
if ($isPublic && $isVersion) {
$this->logger->debug(
'Version access with public link is not allowed',
[
'fileId' => $fileId,
'version' => $version
],
);

return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

$guestUserId = 'Guest-' . \OCP\Server::get(\OCP\Security\ISecureRandom::class)->generate(8);
$user = $this->userManager->get($wopi->getEditorUid());
$userDisplayName = $user !== null && !$isPublic ? $user->getDisplayName() : $wopi->getGuestDisplayname();
$isVersion = $version !== '0';
$isSmartPickerEnabled = (bool)$wopi->getCanwrite() && !$isPublic && !$wopi->getDirect();
$isTaskProcessingEnabled = $isSmartPickerEnabled && $this->taskProcessingManager->isTaskProcessingEnabled();

Expand Down Expand Up @@ -353,6 +380,18 @@ public function getFile(
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

if ($wopi->isGuest()) {
try {
$this->wopiRateLimitService->registerRequest($wopi, 'getFile');
} catch (IRateLimitExceededException $e) {
$this->logger->warning('WOPI getFile rate limit exceeded for token {wopiId} from {remoteAddress}', [
'wopiId' => $wopi->getId(),
'remoteAddress' => $this->request->getRemoteAddress(),
]);
return new JSONResponse([], Http::STATUS_TOO_MANY_REQUESTS);
}
}

if ((int)$fileId !== $wopi->getFileid()) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
Expand All @@ -362,6 +401,18 @@ public function getFile(
$file = $this->getFileForWopiToken($wopi);
\OC_User::setIncognitoMode(true);
if ($version !== '0') {
if (empty($wopi->getEditorUid())) {
$this->logger->debug(
'Version access with public link is not allowed',
[
'fileId' => $fileId,
'version' => $version
],
);

return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

$versionManager = \OCP\Server::get(IVersionManager::class);
$info = $versionManager->getVersionFile($this->userManager->get($wopi->getUserForFileAccess()), $file, $version);
if ($info->getSize() === 0) {
Expand Down
33 changes: 33 additions & 0 deletions lib/Service/WopiRateLimitService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\Service;

use OCA\Richdocuments\Db\Wopi;
use OCP\IRequest;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Security\RateLimiting\IRateLimitExceededException;

class WopiRateLimitService {
public function __construct(
private ILimiter $limiter,
private IRequest $request,
) {
}

/**
* @throws IRateLimitExceededException
*/
public function registerRequest(Wopi $wopi, string $action): void {
$this->limiter->registerAnonRequest(
'richdocuments::wopi::' . $action . '::' . $wopi->getId(),
10,
120,
$this->request->getRemoteAddress()
);
}
}
39 changes: 39 additions & 0 deletions tests/features/bootstrap/WopiContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,43 @@ public function collaboraRenamesTo($fileId, $newName) {
$this->response = $e->getResponse();
}
}

/**
* @When /^Collabora fetches checkFileInfo for version "([^"]*)"$/
*/
public function collaboraFetchesCheckFileInfoForVersion($version) {
$client = new Client();
// Ensure we set the version as the third underscore-separated part
$arr = explode('_', $this->fileId);
if (count($arr) >= 3) {
$arr[2] = (string)$version;
} else {
$arr[] = (string)$version;
}
$fid = implode('_', $arr);
$options = [];
try {
$this->response = $client->get($this->getWopiEndpointBaseUrl() . 'index.php/apps/richdocuments/wopi/files/' . $fid . '?access_token=' . $this->wopiToken, $options);
$this->checkFileInfoResult = json_decode($this->response->getBody()->getContents(), true);
} catch (\GuzzleHttp\Exception\ClientException $e) {
$this->response = $e->getResponse();
}
}

/**
* @When /^I perform "(\d+)" guest checkFileInfo requests$/
*/
public function performGuestCheckFileInfoRequests($count) {
$client = new Client();
$last = null;
for ($i = 0; $i < intval($count); $i++) {
try {
$resp = $client->get($this->getWopiEndpointBaseUrl() . 'index.php/apps/richdocuments/wopi/files/' . $this->fileId . '?access_token=' . $this->wopiToken);
$last = $resp;
} catch (\GuzzleHttp\Exception\ClientException $e) {
$last = $e->getResponse();
}
$this->response = $last;
}
}
}
25 changes: 24 additions & 1 deletion tests/features/wopi.feature
Original file line number Diff line number Diff line change
Expand Up @@ -375,4 +375,27 @@ Feature: WOPI
And as "user1" rename "/SharedFolder/file.odt" to "renamed_file"
And as "user1" the file "/SharedFolder/renamed_file.odt" exists
And as "user1" the file "/SharedFolder/file.odt" does not exist
And as "user1" the file "/renamed_file.odt" does not exist
And as "user1" the file "/renamed_file.odt" does not exist


Scenario: Public share cannot request a specific saved version
Given as user "user1"
And User "user1" uploads file "./../emptyTemplates/template.odt" to "/file.odt"
And as "user1" create a share with
| path | /file.odt |
| shareType | 3 |
Then Using web as guest
And a guest opens the share link
When Collabora fetches checkFileInfo for version "1"
Then the WOPI HTTP status code should be "403"

Scenario: Guest repeated checkFileInfo requests are rate-limited
Given as user "user1"
And User "user1" uploads file "./../emptyTemplates/template.odt" to "/file.odt"
And as "user1" create a share with
| path | /file.odt |
| shareType | 3 |
Then Using web as guest
And a guest opens the share link
When I perform "11" guest checkFileInfo requests
Then the WOPI HTTP status code should be "429"
64 changes: 64 additions & 0 deletions tests/lib/Service/WopiRateLimitServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Richdocuments;

use OCA\Richdocuments\Db\Wopi;
use OCA\Richdocuments\Service\WopiRateLimitService;
use OCP\IRequest;
use OCP\Security\RateLimiting\ILimiter;
use OCP\Security\RateLimiting\IRateLimitExceededException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class WopiRateLimitServiceTest extends TestCase {
private ILimiter&MockObject $limiter;
private IRequest&MockObject $request;
private WopiRateLimitService $service;

protected function setUp(): void {
parent::setUp();
$this->limiter = $this->createMock(ILimiter::class);
$this->request = $this->createMock(IRequest::class);
$this->service = new WopiRateLimitService($this->limiter, $this->request);
}

private function createWopiMock(int $id = 42): Wopi&MockObject {
$wopi = $this->getMockBuilder(Wopi::class)
->addMethods(['getId'])
->getMock();
$wopi->method('getId')->willReturn($id);
return $wopi;
}

public function testRegisterRequestCallsLimiterWithCorrectParameters(): void {
$wopi = $this->createWopiMock(7);
$this->request->method('getRemoteAddress')->willReturn('127.0.0.1');

$this->limiter->expects($this->once())
->method('registerAnonRequest')
->with(
'richdocuments::wopi::checkFileInfo::7',
10,
120,
'127.0.0.1'
);

$this->service->registerRequest($wopi, 'checkFileInfo');
}

public function testRegisterRequestPropagatesRateLimitExceeded(): void {
$wopi = $this->createWopiMock();
$this->request->method('getRemoteAddress')->willReturn('127.0.0.1');

$exception = $this->createMock(IRateLimitExceededException::class);
$this->limiter->method('registerAnonRequest')->willThrowException($exception);

$this->expectException(IRateLimitExceededException::class);
$this->service->registerRequest($wopi, 'checkFileInfo');
}
}
Loading