Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
330c3e2
fix(security): validate federation server against trusted-servers list
DeepDiver1975 May 12, 2026
9ed6f19
fix(security): wire TrustedServers injection for FederationService DI
DeepDiver1975 May 12, 2026
da83c50
fix(review): use injected \$server over \OC::\$server, drop redundant…
DeepDiver1975 May 13, 2026
d76b401
fix(style): add space after fn keyword in arrow functions (phpcs)
DeepDiver1975 May 13, 2026
97ff361
fix(phan): suppress undeclared-class warnings for OCA\Federation\Trus…
DeepDiver1975 May 13, 2026
b67fa7f
fix(phan): move PhanUndeclaredTypeProperty suppress to property decla…
DeepDiver1975 May 13, 2026
3653951
docs: add design spec for federation allowlist via system config
DeepDiver1975 May 15, 2026
cc28073
docs: add implementation plan for federation allowlist via system config
DeepDiver1975 May 15, 2026
57d77a9
docs: add pre-PR code style gate task to federation allowlist plan
DeepDiver1975 May 15, 2026
9baaca6
feat(security): replace TrustedServers with richdocuments.federation_…
DeepDiver1975 May 15, 2026
45d01bd
fix: simplify FederationService DI — inject IConfig directly
DeepDiver1975 May 15, 2026
dfc7d6a
chore: ignore docs/superpowers planning artifacts
DeepDiver1975 May 15, 2026
6103230
feat(security): use domain-only entries in federation_allowlist config
DeepDiver1975 May 15, 2026
42d8f74
fix: add (string) cast on preg_replace, drop redundant test, note pat…
DeepDiver1975 May 15, 2026
fddcf61
refactor: remove explicit FederationService DI registration
DeepDiver1975 May 15, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ node_modules

.idea/
.pnpm-store/

# Superpowers planning artifacts
docs/superpowers/
7 changes: 0 additions & 7 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,9 @@ class Application extends App {
public function __construct(array $urlParams = []) {
parent::__construct('richdocuments', $urlParams);

$this->registerServices();
}

private function registerServices() {
$container = $this->getContainer();
$server = $container->getServer();

/**
* Core
*/
$container->registerService('L10N', function (SimpleContainer $c) use ($server) {
return $server->getL10N($c->query('AppName'));
});
Expand Down
63 changes: 40 additions & 23 deletions lib/FederationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,34 @@
*/
namespace OCA\Richdocuments;

use OCP\IConfig;
use OCP\ILogger;
use OCP\Http\Client\IClientService;
use OCP\IURLGenerator;

class FederationService {
/**
* @var ILogger
*/
/** @var ILogger */
private $logger;

/**
* @var IURLGenerator
*/
/** @var IURLGenerator */
private $urlGenerator;

/**
* @var IClientService
*/
/** @var IClientService */
private $httpClient;

/** @var IConfig */
private $config;

public function __construct(
ILogger $logger,
IURLGenerator $urlGenerator,
IClientService $httpClient
IClientService $httpClient,
IConfig $config
) {
$this->logger = $logger;
$this->logger = $logger;
$this->urlGenerator = $urlGenerator;
$this->httpClient = $httpClient;
$this->httpClient = $httpClient;
$this->config = $config;
}

/**
Expand All @@ -69,13 +69,12 @@ public function getRemoteFileUrl(string $shareToken, string $shareRelativePath,
'&accessToken=' . $accessToken;
return $remoteFileUrl;
}

/**
*
* @param string $server addres of a remote server
* @param string $accessToken wopi access token from a remote server
* @return array|null with additional wopi information
*/
* @param string $server address of a remote server
* @param string $accessToken wopi access token from a remote server
* @return array|null with additional wopi information
*/
public function getWopiForToken($server, $accessToken) {
$remote = $server;

Expand Down Expand Up @@ -114,15 +113,33 @@ public function getWopiForToken($server, $accessToken) {
}

/**
* Check if server is allowed
* Check if the given server URL matches an entry in the richdocuments.federation_allowlist
* system config. Allowlist entries are plain domain names (no scheme).
*
* Returns false when the key is absent, empty, or not an array.
*
* Known limitation: path-prefixed installs (e.g. cloud.example.com/owncloud) are not
* supported — the path component will prevent matching a bare domain entry.
*
* @param string $remote a remote url
* @return bool indicating if given remote is allowed server
* @return bool
*/
public function isServerAllowed($remote) {
// TODO: implement check for trusted server, for a moment all trusted
public function isServerAllowed(string $remote): bool {
$allowlist = $this->config->getSystemValue('richdocuments.federation_allowlist', []);

if (!\is_array($allowlist) || empty($allowlist)) {
return false;
}

$domain = \rtrim((string)\preg_replace('|^https?://|', '', $remote), '/');

foreach ($allowlist as $entry) {
if ($domain === \rtrim((string)$entry, '/')) {
return true;
}
}

return true;
return false;
}

/**
Expand Down
121 changes: 87 additions & 34 deletions tests/unit/FederationServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,63 +23,51 @@

use OCA\Richdocuments\FederationService;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\ILogger;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class FederationServiceTest extends TestCase {
/**
* The ILogger instance.
*
* @var ILogger
*/
/** @var ILogger|MockObject */
private $logger;

/**
* The IClientService instance.
*
* @var IClientService
*/
/** @var IClientService|MockObject */
private $httpClient;

/**
* The IURLGenerator instance.
*
* @var IURLGenerator
*/
/** @var IURLGenerator|MockObject */
private $urlGenerator;

/**
* @var FederationService|MockObject The discovery service mock object.
*/
/** @var IConfig|MockObject */
private $config;

/** @var FederationService */
private $federationService;

protected function setUp(): void {
parent::setUp();

$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->logger = $this->createMock(ILogger::class);
$this->httpClient = $this->createMock(IClientService::class);
$this->logger = $this->createMock(ILogger::class);
$this->httpClient = $this->createMock(IClientService::class);
$this->config = $this->createMock(IConfig::class);

$this->federationService = new FederationService(
$this->logger,
$this->urlGenerator,
$this->httpClient
$this->httpClient,
$this->config
);
}

// -------------------------------------------------------------------------
// Existing tests
// -------------------------------------------------------------------------

public function dataGenerateFederatedCloudID() {
$userPrefix = [
'username',
'1234'
];
$remotes = [
'localhost',
'local.host',
'dev.local.host',
'127.0.0.1',
];
$userPrefix = ['username', '1234'];
$remotes = ['localhost', 'local.host', 'dev.local.host', '127.0.0.1'];

$testCases = [];
foreach ($userPrefix as $user) {
Expand All @@ -92,9 +80,6 @@ public function dataGenerateFederatedCloudID() {

/**
* @dataProvider dataGenerateFederatedCloudID
*
* @param string $userId
* @param string $expectedFederatedCloudID
*/
public function testSplitUserRemote($userId, $remote) {
$this->urlGenerator->method('getAbsoluteUrl')
Expand All @@ -105,4 +90,72 @@ public function testSplitUserRemote($userId, $remote) {

$this->assertSame("{$userId}@{$remote}", $federatedCloudID);
}

// -------------------------------------------------------------------------
// isServerAllowed() tests
// -------------------------------------------------------------------------

public function testIsServerAllowedReturnsFalseWhenKeyIsAbsent(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn([]);

$this->assertFalse($this->federationService->isServerAllowed('https://remote.example.com'));
}

public function testIsServerAllowedReturnsFalseForNonArrayConfig(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn('not-an-array');

$this->assertFalse($this->federationService->isServerAllowed('https://remote.example.com'));
}

public function testIsServerAllowedReturnsTrueForExactMatch(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertTrue($this->federationService->isServerAllowed('https://trusted.example.com'));
}

public function testIsServerAllowedStripsTrailingSlash(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertTrue($this->federationService->isServerAllowed('https://trusted.example.com/'));
}

public function testIsServerAllowedStripsMultipleTrailingSlashes(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertTrue($this->federationService->isServerAllowed('https://trusted.example.com///'));
}

public function testIsServerAllowedMatchesHttps(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertTrue($this->federationService->isServerAllowed('https://trusted.example.com'));
}

public function testIsServerAllowedMatchesHttp(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertTrue($this->federationService->isServerAllowed('http://trusted.example.com'));
}

public function testIsServerAllowedReturnsFalseForUntrustedServer(): void {
$this->config->method('getSystemValue')
->with('richdocuments.federation_allowlist', [])
->willReturn(['trusted.example.com']);

$this->assertFalse($this->federationService->isServerAllowed('https://evil.attacker.com'));
}
}
Loading