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
11 changes: 8 additions & 3 deletions lib/Listeners/CSPListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ private function getAllowedFrameDomains(): array {

$rawAllowlist = trim((string)$this->config->getAppValue('etherpad_nextcloud', 'external_pad_allowlist', ''));
if ($rawAllowlist === '') {
// Empty allowlist means "allow all public HTTPS hosts" for external pad linking.
// Mirror that behavior in CSP so valid external pads can actually load in the iframe.
$domains['https:'] = true;
// An empty allowlist means "allow all public HTTPS hosts" for the
// server-side snapshot fetch (see ExternalPadExportFetcher), but we
// must NOT mirror that into CSP: this listener handles *every*
// AddContentSecurityPolicyEvent, so emitting `https:` here would
// relax frame-src/child-src on all of Nextcloud, not just pad pages.
// External pads are shown as locally rendered snapshots (no live
// iframe to the external host), so framing them is unnecessary;
// only concrete allowlisted hosts ever get a frame relaxation.
return array_keys($domains);
}

Expand Down
51 changes: 42 additions & 9 deletions tests/phpunit/unit/CSPListenerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ static function (string $app, string $key, string $default = ''): string {
], $policies[0]->getAllowedChildSrcDomains());
}

public function testHandleAllowsAllHttpsWhenExternalAllowlistIsEmpty(): void {
public function testHandleNeverEmitsBlanketHttpsWhenExternalAllowlistIsEmpty(): void {
// Regression for #102: external pads enabled + empty allowlist must NOT
// relax frame-src/child-src to `https:` globally. Only the concrete
// configured Etherpad host is allowed to be framed.
$config = $this->createMock(IConfig::class);
$config->method('getAppValue')->willReturnCallback(
static function (string $app, string $key, string $default = ''): string {
Expand All @@ -70,13 +73,43 @@ static function (string $app, string $key, string $default = ''): string {

$policies = $event->getPolicies();
$this->assertCount(1, $policies);
$this->assertSame([
'https://pad.jaggob.uber.space',
'https:',
], $policies[0]->getAllowedFrameDomains());
$this->assertSame([
'https://pad.jaggob.uber.space',
'https:',
], $policies[0]->getAllowedChildSrcDomains());
$this->assertSame(
['https://pad.jaggob.uber.space'],
$policies[0]->getAllowedFrameDomains()
);
$this->assertSame(
['https://pad.jaggob.uber.space'],
$policies[0]->getAllowedChildSrcDomains()
);
$this->assertNotContains('https:', $policies[0]->getAllowedFrameDomains());
$this->assertNotContains('https:', $policies[0]->getAllowedChildSrcDomains());
}

public function testHandleEmitsNoPolicyWhenOnlyExternalEmptyAllowlistAndNoLocalHost(): void {
// With no local Etherpad host and an empty external allowlist there is
// nothing concrete to allow, so the listener must add no policy at all
// (rather than a blanket `https:`).
$config = $this->createMock(IConfig::class);
$config->method('getAppValue')->willReturnCallback(
static function (string $app, string $key, string $default = ''): string {
if ($app !== 'etherpad_nextcloud') {
return $default;
}

return match ($key) {
'etherpad_host' => '',
'allow_external_pads' => 'yes',
'external_pad_allowlist' => '',
default => $default,
};
}
);

$listener = new CSPListener($config);
$event = new AddContentSecurityPolicyEvent();

$listener->handle($event);

$this->assertSame([], $event->getPolicies());
}
}