Add CSRF token validation for quiz-related actions

- Added CSRF validation to `enableQuiz`, `clearQuiz`, `deleteQuiz`, `toggleCandidate`, and `prepareElimination` actions.
- Updated Twig templates to replace links with POST forms to include CSRF tokens.
- Set HTTP method restrictions for related endpoints to `POST`.
This commit is contained in:
2026-05-24 14:15:12 +02:00
parent cab2d25000
commit 246ff999d3
5 changed files with 65 additions and 25 deletions
@@ -24,9 +24,14 @@ final class PrepareEliminationController extends AbstractController
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
name: 'tvdt_prepare_elimination',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function index(Season $season, Quiz $quiz): RedirectResponse
public function index(Season $season, Quiz $quiz, Request $request): RedirectResponse
{
if (!$this->isCsrfTokenValid('prepare_elimination', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$elimination = $this->eliminationFactory->createEliminationFromQuiz($quiz);
return $this->redirectToRoute('tvdt_prepare_elimination_view', ['elimination' => $elimination->id]);
+24 -5
View File
@@ -250,9 +250,14 @@ class QuizController extends AbstractController
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/enable',
name: 'tvdt_backoffice_enable',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID.'|null'],
methods: ['POST'],
)]
public function enableQuiz(Season $season, ?Quiz $quiz): RedirectResponse
public function enableQuiz(Season $season, ?Quiz $quiz, Request $request): RedirectResponse
{
if (!$this->isCsrfTokenValid('enable_quiz', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$season->activeQuiz = $quiz;
$this->em->flush();
@@ -268,9 +273,14 @@ class QuizController extends AbstractController
'/backoffice/quiz/{quiz}/clear',
name: 'tvdt_backoffice_quiz_clear',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function clearQuiz(Quiz $quiz): RedirectResponse
public function clearQuiz(Quiz $quiz, Request $request): RedirectResponse
{
if (!$this->isCsrfTokenValid('clear_quiz', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
try {
$this->quizRepository->clearQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz cleared'));
@@ -286,9 +296,14 @@ class QuizController extends AbstractController
'/backoffice/quiz/{quiz}/delete',
name: 'tvdt_backoffice_quiz_delete',
requirements: ['quiz' => Requirement::UUID],
methods: ['POST'],
)]
public function deleteQuiz(Quiz $quiz): RedirectResponse
public function deleteQuiz(Quiz $quiz, Request $request): RedirectResponse
{
if (!$this->isCsrfTokenValid('delete_quiz', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$this->quizRepository->deleteQuiz($quiz);
$this->addFlash('success', $this->translator->trans('Quiz deleted'));
@@ -347,10 +362,14 @@ class QuizController extends AbstractController
'/backoffice/quiz/{quiz}/candidate/{candidate}/toggle',
name: 'tvdt_backoffice_toggle_candidate',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['GET'],
methods: ['POST'],
)]
public function toggleCandidate(Quiz $quiz, Candidate $candidate): RedirectResponse
public function toggleCandidate(Quiz $quiz, Candidate $candidate, Request $request): RedirectResponse
{
if (!$this->isCsrfTokenValid('toggle_candidate', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$quizCandidate = $this->quizCandidateRepository->findOneBy([
'quiz' => $quiz,
'candidate' => $candidate,
@@ -35,14 +35,16 @@
{% endif %}
</td>
<td>
<a href="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}"
class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %}
{{ 'Deactivate'|trans }}
{% else %}
{{ 'Activate'|trans }}
{% endif %}
</a>
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %}
{{ 'Deactivate'|trans }}
{% else %}
{{ 'Activate'|trans }}
{% endif %}
</button>
</form>
</td>
</tr>
{% endfor %}
@@ -3,16 +3,24 @@
<div class="mb-3 btn-group">
{% if quiz is same as (season.activeQuiz) %}
<a class="btn btn-secondary"
href="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}">{{ 'Deactivate Quiz'|trans }}</a>
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
{{ 'Deactivate Quiz'|trans }}
</button>
</form>
{% else %}
<a class="btn btn-primary"
href="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">{{ 'Make active'|trans }}</a>
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<button type="submit" class="btn btn-primary rounded-0 rounded-start">
{{ 'Make active'|trans }}
</button>
</form>
{% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear Quiz...'|trans }}
</button>
<button class="btn btn-danger" data-action="click->bo--quiz#deleteQuiz">
<button class="btn btn-danger rounded-0 rounded-end" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }}
</button>
</div>
@@ -74,8 +82,10 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<a href="{{ path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}) }}"
class="btn btn-danger">{{ 'Yes'|trans }}</a>
<form action="{{ path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('clear_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
@@ -97,8 +107,10 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<a href="{{ path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}) }}"
class="btn btn-danger">{{ 'Yes'|trans }}</a>
<form action="{{ path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
@@ -2,8 +2,10 @@
<div class="btn-toolbar mb-3" role="toolbar">
<div class="btn-group me-2">
{# <a class="btn btn-primary">{{ 'Start Elimination'|trans }}</a> #}
<a href="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
class="btn btn-secondary">{{ 'Prepare Custom Elimination'|trans }}</a>
<form action="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('prepare_elimination') }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">{{ 'Prepare Custom Elimination'|trans }}</button>
</form>
{%~ if not quiz.eliminations.empty %}
<button class="btn btn-secondary dropdown-toggle"
data-bs-toggle="dropdown">{{ 'Load Prepared Elimination'|trans }}</button>