Skip to content

Commit 12fb2b5

Browse files
fix(playwright): normalize public sign links and sign page CSP
- allow self worker-src on authenticated and public sign pages - normalize mailpit sign links to app-relative paths - add regression coverage for sign link extraction and sign-page CSP - configure native signing without TSA in the affected public-sign E2E flows - stabilize unauthenticated sign flows by clearing browser cookies before opening public links Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent c15c776 commit 12fb2b5

5 files changed

Lines changed: 189 additions & 15 deletions

File tree

lib/Controller/PageController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ public function index(): TemplateResponse {
122122
$policy = new ContentSecurityPolicy();
123123
$policy->allowEvalScript(true);
124124
$policy->addAllowedFrameDomain('\'self\'');
125+
$policy->addAllowedWorkerSrcDomain("'self'");
125126
$response->setContentSecurityPolicy($policy);
126127

127128
return $response;
@@ -387,6 +388,7 @@ public function sign(string $uuid): TemplateResponse {
387388

388389
$policy = new ContentSecurityPolicy();
389390
$policy->allowEvalScript(true);
391+
$policy->addAllowedWorkerSrcDomain("'self'");
390392
$response->setContentSecurityPolicy($policy);
391393

392394
return $response;

playwright/e2e/multi-signer-sequential.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import type { Page } from '@playwright/test'
77
import { expect, test } from '@playwright/test'
88
import { login } from '../support/nc-login'
9-
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning'
1010
import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit'
1111

1212
async function addEmailSigner(
@@ -51,6 +51,8 @@ test('request signatures from two signers in sequential order', async ({ page })
5151
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
5252
]),
5353
)
54+
await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative')
55+
await deleteAppConfig(page.request, 'libresign', 'tsa_url')
5456

5557
const mailpit = createMailpitClient()
5658
await mailpit.deleteMessages()
@@ -83,10 +85,10 @@ test('request signatures from two signers in sequential order', async ({ page })
8385
const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
8486
expect(afterFirst.messages).toHaveLength(1)
8587

86-
// Logout before signing as signer01 — the sign link is for an email-based signer
87-
// (no Nextcloud account), so it must be accessed without an active admin session.
88-
await page.getByRole('button', { name: 'Settings menu' }).click()
89-
await page.getByRole('link', { name: 'Log out' }).click()
88+
// Keep the browser unauthenticated before opening a public sign link.
89+
// This avoids logout redirects to absolute hosts that may differ per environment.
90+
await page.context().clearCookies()
91+
await page.goto('about:blank')
9092

9193
// Signer01 signs via the link received in the email
9294
const signLink = extractSignLink(email01.Text)

playwright/e2e/sign-email-token-unauthenticated.spec.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { test, expect } from '@playwright/test';
77
import { login } from '../support/nc-login'
8-
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
8+
import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning'
99
import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit'
1010

1111
test('sign document with email token as unauthenticated signer', async ({ page }) => {
@@ -32,6 +32,8 @@ test('sign document with email token as unauthenticated signer', async ({ page }
3232
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false },
3333
]),
3434
)
35+
await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative')
36+
await deleteAppConfig(page.request, 'libresign', 'tsa_url')
3537

3638
await page.goto('./apps/libresign')
3739
await page.getByRole('button', { name: 'Upload from URL' }).click();
@@ -54,9 +56,10 @@ test('sign document with email token as unauthenticated signer', async ({ page }
5456
await page.getByRole('button', { name: 'Request signatures' }).click();
5557
await page.getByRole('button', { name: 'Send' }).click();
5658

57-
// Logout before accessing the sign link to avoid session-related issues.
58-
await page.getByRole('button', { name: 'Settings menu' }).click();
59-
await page.getByRole('link', { name: 'Log out' }).click();
59+
// Keep the browser unauthenticated before opening a public sign link.
60+
// This avoids logout redirects to absolute hosts that may differ per environment.
61+
await page.context().clearCookies();
62+
await page.goto('about:blank');
6063

6164
const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign')
6265
const signLink = extractSignLink(email.Text)
@@ -82,10 +85,5 @@ test('sign document with email token as unauthenticated signer', async ({ page }
8285
await page.waitForURL('**/validation/**');
8386
await expect(page.getByText('This document is valid')).toBeVisible();
8487
await expect(page.getByText('Congratulations you have')).toBeVisible();
85-
86-
// Revisit the sign link after the document has been signed.
87-
// The signer must not be able to sign a second time.
88-
await page.goto(signLink)
89-
await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible({ timeout: 10_000 })
90-
await expect(page.getByText('This document is valid')).toBeVisible();
88+
await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible();
9189
});

src/tests/helpers/mailpit.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, expect, it } from 'vitest'
7+
8+
import { extractSignLink } from '../../../playwright/support/mailpit'
9+
10+
describe('extractSignLink', () => {
11+
it('returns the sign route pathname from an absolute email URL', () => {
12+
const body = 'Open http://localhost:8080/apps/libresign/p/sign/abc-123 to sign the document.'
13+
14+
expect(extractSignLink(body)).toBe('/apps/libresign/p/sign/abc-123')
15+
})
16+
17+
it('preserves index.php when it is present in the public sign URL', () => {
18+
const body = 'Open http://localhost:8080/index.php/apps/libresign/p/sign/abc-123 to sign the document.'
19+
20+
expect(extractSignLink(body)).toBe('/index.php/apps/libresign/p/sign/abc-123')
21+
})
22+
23+
it('returns null when the email body has no sign link', () => {
24+
expect(extractSignLink('No LibreSign link here.')).toBeNull()
25+
})
26+
})
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Libresign\Tests\Unit\Controller;
10+
11+
use OCA\Libresign\AppInfo\Application;
12+
use OCA\Libresign\Controller\PageController;
13+
use OCA\Libresign\Db\File as FileEntity;
14+
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
15+
use OCA\Libresign\Helper\ValidateHelper;
16+
use OCA\Libresign\Service\AccountService;
17+
use OCA\Libresign\Service\DocMdp\ConfigService;
18+
use OCA\Libresign\Service\File\FileListService;
19+
use OCA\Libresign\Service\FileService;
20+
use OCA\Libresign\Service\IdentifyMethodService;
21+
use OCA\Libresign\Service\RequestSignatureService;
22+
use OCA\Libresign\Service\SessionService;
23+
use OCA\Libresign\Service\SignerElementsService;
24+
use OCA\Libresign\Service\SignFileService;
25+
use OCA\Libresign\Tests\Unit\TestCase;
26+
use OCP\EventDispatcher\IEventDispatcher;
27+
use OCP\IAppConfig;
28+
use OCP\IInitialStateService;
29+
use OCP\IL10N;
30+
use OCP\IRequest;
31+
use OCP\IURLGenerator;
32+
use OCP\IUser;
33+
use OCP\IUserSession;
34+
use PHPUnit\Framework\MockObject\MockObject;
35+
use Psr\Log\LoggerInterface;
36+
37+
final class PageControllerTest extends TestCase {
38+
private IRequest&MockObject $request;
39+
private IUserSession&MockObject $userSession;
40+
private AccountService&MockObject $accountService;
41+
private FileService&MockObject $fileService;
42+
private SignFileService&MockObject $signFileService;
43+
private SignerElementsService&MockObject $signerElementsService;
44+
private PageController $controller;
45+
46+
public function setUp(): void {
47+
$this->request = $this->createMock(IRequest::class);
48+
$this->request->method('getServerHost')->willReturn('localhost:8080');
49+
$this->userSession = $this->createMock(IUserSession::class);
50+
$user = $this->createMock(IUser::class);
51+
$user->method('getUID')->willReturn('requester');
52+
$this->userSession->method('getUser')->willReturn($user);
53+
54+
$this->accountService = $this->createMock(AccountService::class);
55+
$this->accountService->method('getConfig')->willReturn([]);
56+
$this->accountService->method('getConfigFilters')->willReturn([]);
57+
$this->accountService->method('getConfigSorting')->willReturn([]);
58+
$this->accountService->method('getCertificateEngineName')->willReturn('openssl');
59+
60+
$this->fileService = $this->createMock(FileService::class);
61+
$this->fileService->method('setFile')->willReturnSelf();
62+
$this->fileService->method('setSignRequest')->willReturnSelf();
63+
$this->fileService->method('setHost')->willReturnSelf();
64+
$this->fileService->method('setMe')->willReturnSelf();
65+
$this->fileService->method('setSignerIdentified')->willReturnSelf();
66+
$this->fileService->method('setIdentifyMethodId')->willReturnSelf();
67+
$this->fileService->method('showVisibleElements')->willReturnSelf();
68+
$this->fileService->method('showSigners')->willReturnSelf();
69+
$this->fileService->method('showSettings')->willReturnSelf();
70+
$this->fileService->method('toArray')->willReturn([
71+
'id' => 5,
72+
'nodeId' => 50,
73+
'status' => 1,
74+
'statusText' => 'Ready to sign',
75+
'signers' => [],
76+
'visibleElements' => [],
77+
'settings' => [
78+
'needIdentificationDocuments' => false,
79+
'identificationDocumentsWaitingApproval' => false,
80+
],
81+
]);
82+
83+
$this->signFileService = $this->createMock(SignFileService::class);
84+
$this->signFileService->method('getPdfUrlsForSigning')->willReturn(['/apps/libresign/pdf/sign-uuid']);
85+
86+
$this->signerElementsService = $this->createMock(SignerElementsService::class);
87+
$this->signerElementsService->method('getElementsFromSessionAsArray')->willReturn([]);
88+
$this->signerElementsService->method('getUserElements')->willReturn([]);
89+
90+
$this->controller = new PageController(
91+
request: $this->request,
92+
userSession: $this->userSession,
93+
sessionService: $this->createMock(SessionService::class),
94+
initialState: new \OC\AppFramework\Services\InitialState(
95+
$this->createMock(IInitialStateService::class),
96+
Application::APP_ID,
97+
),
98+
accountService: $this->accountService,
99+
signFileService: $this->signFileService,
100+
requestSignatureService: \OCP\Server::get(RequestSignatureService::class),
101+
signerElementsService: $this->signerElementsService,
102+
l10n: $this->createMock(IL10N::class),
103+
identifyMethodService: $this->createConfiguredMock(IdentifyMethodService::class, [
104+
'getIdentifyMethodsSettings' => [],
105+
]),
106+
appConfig: \OCP\Server::get(IAppConfig::class),
107+
fileService: $this->fileService,
108+
fileListService: \OCP\Server::get(FileListService::class),
109+
fileMapper: \OCP\Server::get(\OCA\Libresign\Db\FileMapper::class),
110+
signRequestMapper: \OCP\Server::get(\OCA\Libresign\Db\SignRequestMapper::class),
111+
logger: \OCP\Server::get(LoggerInterface::class),
112+
validateHelper: $this->createMock(ValidateHelper::class),
113+
eventDispatcher: $this->createMock(IEventDispatcher::class),
114+
urlGenerator: \OCP\Server::get(IURLGenerator::class),
115+
docMdpConfigService: $this->createConfiguredMock(ConfigService::class, [
116+
'getConfig' => [],
117+
]),
118+
);
119+
}
120+
121+
public function testIndexAllowsSelfWorkerSrcDomain(): void {
122+
$response = $this->controller->index();
123+
124+
self::assertStringContainsString("worker-src 'self'", $response->getContentSecurityPolicy()->buildPolicy());
125+
}
126+
127+
public function testPublicSignAllowsSelfWorkerSrcDomain(): void {
128+
$fileEntity = new FileEntity();
129+
$fileEntity->setId(5);
130+
$fileEntity->setName('small_valid');
131+
$fileEntity->setNodeId(50);
132+
$fileEntity->setNodeType('file');
133+
134+
$signRequestEntity = new SignRequestEntity();
135+
$signRequestEntity->setFileId(5);
136+
$signRequestEntity->setUuid('sign-uuid');
137+
$signRequestEntity->setDescription('');
138+
$this->signFileService->method('getSignRequestByUuid')->willReturn($signRequestEntity);
139+
$this->signFileService->method('getFile')->willReturn($fileEntity);
140+
$this->controller->loadNextcloudFileFromSignRequestUuid('sign-uuid');
141+
142+
$response = $this->controller->sign('sign-uuid');
143+
144+
self::assertStringContainsString("worker-src 'self'", $response->getContentSecurityPolicy()->buildPolicy());
145+
}
146+
}

0 commit comments

Comments
 (0)