Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Prevent race condition on unique-submission forms #1841

Merged
merged 1 commit into from
Jan 9, 2024
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
18 changes: 13 additions & 5 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1055,11 +1055,6 @@
throw new OCSForbiddenException('This form is no longer taking answers');
}

// Does the user have permissions to submit
if (!$this->formsService->canSubmit($form)) {
throw new OCSForbiddenException('Already submitted');
}

// Is the submission valid
if (!$this->submissionService->validateSubmission($questions, $answers)) {
throw new OCSBadRequestException('At least one submitted answer is not valid');
Expand All @@ -1078,10 +1073,23 @@
$submission->setUserId($this->currentUser->getUID());
}

// Does the user have permissions to submit
// This is done right before insert so we minimize race conditions for submitting on unique-submission forms
if (!$this->formsService->canSubmit($form)) {
throw new OCSForbiddenException('Already submitted');
}

// Insert new submission
$this->submissionMapper->insert($submission);
$submissionId = $submission->getId();

// Ensure the form is unique if needed.
// If we can not submit anymore then the submission must be unique
if (!$this->formsService->canSubmit($form) && !$this->submissionService->isUniqueSubmission($submission)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!$this->formsService->canSubmit($form) && !$this->submissionService->isUniqueSubmission($submission)) {
if (!$this->formsService->canSubmit($form) && !$this->submissionService->isUniqueSubmission($submission)) {

Since the first part of this condition already exists up exactly and throws an exception, if this exception exists this part of the code would never be reached?

Or does canSubmit result change after the first insertion?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

@susnux susnux Dec 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is here if the user still can submit after insert then multiple submissions are allowed. If not submissions have to be unique and we need to verify the answer is the only one.

While writing this: Is this not a (unrelated to this PR) bug? If unique submissions are enabled why should the owner be allowed to submit multiple?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why should the owner be allowed to submit multiple?

Good question, the only reason I could think of: The owner fills the form for someone else who can't access the online form for some reason but needs to?

This part of the code was added by @jotoeri 2 years ago. So let's ask him what was his intention :)

Copy link
Member

@jotoeri jotoeri Dec 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, i'm not 100% sure, but i think we shortly discussed that somewhere that once. Otherwise that change wouldn't have gone through just like that.^^
Anyways: I would interpret the Owner as some kind of an administrator for the form, being allowed to do everything with it. The owner could also just remove the submitOnce, submit and then re-set it, which is just annoying handling. So why should we block the owner of doing whatever he/she wants to do with the form?
In fact it's mostly a UX-Question wrt. the owner, no?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fenn-cs do the comments help you to finish your review?

$this->submissionMapper->delete($submission);
throw new OCSForbiddenException('Already submitted');

Check warning on line 1090 in lib/Controller/ApiController.php

View check run for this annotation

Codecov / codecov/patch

lib/Controller/ApiController.php#L1089-L1090

Added lines #L1089 - L1090 were not covered by tests
}

// Process Answers
foreach ($answers as $questionId => $answerArray) {
// Search corresponding Question, skip processing if not found
Expand Down
12 changes: 9 additions & 3 deletions lib/Db/SubmissionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,22 @@
}

/**
* Count submissions by form and optionally also by userId
* @param int $formId ID of the form to count submissions for
* @param string|null $userId optionally limit submissions to the one of that user
* @throws \Exception
*/
public function countSubmissions(int $formId): int {
public function countSubmissions(int $formId, ?string $userId = null): int {

Check warning on line 118 in lib/Db/SubmissionMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/SubmissionMapper.php#L118

Added line #L118 was not covered by tests
$qb = $this->db->getQueryBuilder();

$qb->select($qb->func()->count('*', 'num_submissions'))
$query = $qb->select($qb->func()->count('*', 'num_submissions'))

Check warning on line 121 in lib/Db/SubmissionMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/SubmissionMapper.php#L121

Added line #L121 was not covered by tests
->from($this->getTableName())
->where($qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)));
if (!is_null($userId)) {
$query->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)));

Check warning on line 125 in lib/Db/SubmissionMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/SubmissionMapper.php#L124-L125

Added lines #L124 - L125 were not covered by tests
}

$result = $qb->executeQuery();
$result = $query->executeQuery();

Check warning on line 128 in lib/Db/SubmissionMapper.php

View check run for this annotation

Codecov / codecov/patch

lib/Db/SubmissionMapper.php#L128

Added line #L128 was not covered by tests
$row = $result->fetch();
$result->closeCursor();

Expand Down
8 changes: 8 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Files\File;
Expand Down Expand Up @@ -153,6 +154,13 @@ public function getSubmissions(int $formId): array {
}
}

/**
* Validate the new submission is unique
*/
public function isUniqueSubmission(Submission $newSubmission): bool {
return $this->submissionMapper->countSubmissions($newSubmission->getFormId(), $newSubmission->getUserId()) === 1;
}

/**
* Export Submissions to Cloud-Filesystem
* @param string $hash of the form
Expand Down
4 changes: 4 additions & 0 deletions tests/Unit/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,10 @@ public function testInsertSubmission_forbiddenException($hasUserAccess, $hasForm
->with(1)
->willReturn($form);

$this->submissionService
->method('validateSubmission')
->willReturn(true);

$this->formAccess($hasUserAccess, $hasFormExpired, $canSubmit);

$this->expectException(OCSForbiddenException::class);
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Service/SubmissionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,41 @@ public function setUp(): void {
);
}

/**
* @dataProvider dataIsUniqueSubmission
*/
public function testIsUniqueSubmission(array $submissionData, int $numberOfSubmissions, bool $expected) {
$this->submissionMapper->method('countSubmissions')
->with($submissionData['formId'], $submissionData['userId'])
->willReturn($numberOfSubmissions);

$submission = Submission::fromParams($submissionData);
$this->assertEquals($expected, $this->submissionService->isUniqueSubmission($submission));
}

public function dataIsUniqueSubmission() {
return [
[
'submissionData' => [
'id' => 1,
'userId' => 'user',
'formId' => 1,
],
'numberOfSubmissions' => 1,
'expected' => true,
],
[
'submissionData' => [
'id' => 3,
'userId' => 'user',
'formId' => 1,
],
'numberOfSubmissions' => 2,
'expected' => false,
],
];
}

public function testGetSubmissions() {
$submission_1 = new Submission();
$submission_1->setId(42);
Expand Down