From 9d8a73c3551c5931e220ba50329ab5c687654028 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 11:42:46 -0400 Subject: [PATCH] Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/bootstrap.php's centralized CSRF handling echoes CsrfMiddleware::getToken() on a 403 rejection specifically so lt.api's client-side resync (assets/js/base.js) can recover once window.CSRF_TOKEN goes stale (token expiry, or a write in another tab rotating the shared session-scoped token). 12 endpoints duplicate CsrfMiddleware::validateToken() inline instead of routing through bootstrap.php, and their 403 body omitted csrf_token entirely — custom_fields.php, clone_ticket.php, delete_comment.php, delete_attachment.php, bulk_operation.php, generate_api_key.php, manage_templates.php, manage_recurring.php, revoke_api_key.php, manage_workflows.php, ticket_dependencies.php, and upload_attachment.php. Once a client's token drifted out of sync, the next write to any of these 12 endpoints returned a 403 with no way to self-heal — every subsequent write to any endpoint kept failing until a manual reload, since the resync mechanism was only wired up on a minority of the app's write surface. Took the minimal fix the issue names as sufficient (add 'csrf_token' => CsrfMiddleware::getToken() to each rejection body) rather than restructuring all 12 through bootstrap.php, to avoid behavioral risk from rewiring each endpoint's differing auth/bootstrapping. generate_api_key.php and revoke_api_key.php threw a generic Exception for this case (swallowed into a plain error-message response with no room for extra fields), so those two now short-circuit with a direct JSON response instead, matching the other 10. Verified end-to-end against real running endpoints with a real session and real MariaDB: sent a wrong CSRF token to one endpoint of each response shape (plain json_encode, ResponseHelper::error, and the formerly exception-based path) and confirmed all three now return the current valid csrf_token in the 403 body. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv --- api/bulk_operation.php | 2 +- api/clone_ticket.php | 2 +- api/custom_fields.php | 2 +- api/delete_attachment.php | 2 +- api/delete_comment.php | 2 +- api/generate_api_key.php | 9 ++++++++- api/manage_recurring.php | 2 +- api/manage_templates.php | 2 +- api/manage_workflows.php | 2 +- api/revoke_api_key.php | 9 ++++++++- api/ticket_dependencies.php | 2 +- api/upload_attachment.php | 2 +- 12 files changed, 26 insertions(+), 12 deletions(-) diff --git a/api/bulk_operation.php b/api/bulk_operation.php index fe4f521..7029c7f 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -25,7 +25,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/clone_ticket.php b/api/clone_ticket.php index bb41a16..6862277 100644 --- a/api/clone_ticket.php +++ b/api/clone_ticket.php @@ -34,7 +34,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } diff --git a/api/custom_fields.php b/api/custom_fields.php index 2ca16a9..fa3b5ba 100644 --- a/api/custom_fields.php +++ b/api/custom_fields.php @@ -40,7 +40,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/delete_attachment.php b/api/delete_attachment.php index 3a8ade7..8a4cf82 100644 --- a/api/delete_attachment.php +++ b/api/delete_attachment.php @@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Verify CSRF token $csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } // Get attachment ID diff --git a/api/delete_comment.php b/api/delete_comment.php index 9b11935..8576497 100644 --- a/api/delete_comment.php +++ b/api/delete_comment.php @@ -49,7 +49,7 @@ try { if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } diff --git a/api/generate_api_key.php b/api/generate_api_key.php index 855d9e3..92fdf19 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -39,8 +39,15 @@ try { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { + ob_end_clean(); http_response_code(403); - throw new Exception("Invalid CSRF token"); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Invalid CSRF token', + 'csrf_token' => CsrfMiddleware::getToken() + ]); + exit; } } diff --git a/api/manage_recurring.php b/api/manage_recurring.php index 79c196e..85cb156 100644 --- a/api/manage_recurring.php +++ b/api/manage_recurring.php @@ -42,7 +42,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/manage_templates.php b/api/manage_templates.php index e89067d..31bcc56 100644 --- a/api/manage_templates.php +++ b/api/manage_templates.php @@ -39,7 +39,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/manage_workflows.php b/api/manage_workflows.php index 6961d2f..e6d12aa 100644 --- a/api/manage_workflows.php +++ b/api/manage_workflows.php @@ -40,7 +40,7 @@ try { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]); exit; } } diff --git a/api/revoke_api_key.php b/api/revoke_api_key.php index fe2bb7f..beede25 100644 --- a/api/revoke_api_key.php +++ b/api/revoke_api_key.php @@ -39,8 +39,15 @@ try { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { + ob_end_clean(); http_response_code(403); - throw new Exception("Invalid CSRF token"); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Invalid CSRF token', + 'csrf_token' => CsrfMiddleware::getToken() + ]); + exit; } } diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php index f75b3d5..d969156 100644 --- a/api/ticket_dependencies.php +++ b/api/ticket_dependencies.php @@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } } diff --git a/api/upload_attachment.php b/api/upload_attachment.php index df0ed80..43c8625 100644 --- a/api/upload_attachment.php +++ b/api/upload_attachment.php @@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { // Verify CSRF token $csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { - ResponseHelper::forbidden('Invalid CSRF token'); + ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]); } // Get ticket ID