From cfb88d9c880305a4c73287f0c5dd888c6af87948 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 30 Mar 2026 19:01:18 -0400 Subject: [PATCH] fix: CSRF token staleness causing intermittent 403 on POST actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: bootstrap.php rotates the CSRF token on every successful POST, but most API endpoints called echo json_encode() directly instead of apiRespond() — so the rotated token was never returned to the client. The next POST from the same page sent the now-invalid old token → 403. Refreshing the page loaded a fresh token, making it work once. Fixes: - assign_ticket.php, watch_ticket.php: switch to apiRespond() - saved_filters.php, user_preferences.php: replace all echo json_encode calls with apiRespond() (19 and 12 call sites respectively) - base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN whenever a response includes a csrf_token field, keeping the client permanently in sync with server-side rotations Co-Authored-By: Claude Sonnet 4.6 --- api/assign_ticket.php | 4 ++-- api/saved_filters.php | 38 +++++++++++++++++++------------------- api/user_preferences.php | 24 ++++++++++++------------ api/watch_ticket.php | 3 +-- assets/js/base.js | 2 ++ 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 281453a..ad1fd6e 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -82,7 +82,7 @@ if ($assignedTo === null || $assignedTo === '') { if (!$success) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']); + apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']); } else { - echo json_encode(['success' => true]); + apiRespond(['success' => true]); } diff --git a/api/saved_filters.php b/api/saved_filters.php index bfb99eb..ab2a7cf 100644 --- a/api/saved_filters.php +++ b/api/saved_filters.php @@ -17,23 +17,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $filter = $filtersModel->getFilter($filterId, $userId); if ($filter) { - echo json_encode(['success' => true, 'filter' => $filter]); + apiRespond(['success' => true, 'filter' => $filter]); } else { http_response_code(404); - echo json_encode(['success' => false, 'error' => 'Filter not found']); + apiRespond(['success' => false, 'error' => 'Filter not found']); } } else if (isset($_GET['default'])) { // Get default filter $filter = $filtersModel->getDefaultFilter($userId); - echo json_encode(['success' => true, 'filter' => $filter]); + apiRespond(['success' => true, 'filter' => $filter]); } else { // Get all filters $filters = $filtersModel->getUserFilters($userId); - echo json_encode(['success' => true, 'filters' => $filters]); + apiRespond(['success' => true, 'filters' => $filters]); } } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); + apiRespond(['success' => false, 'error' => 'Failed to fetch filters']); } exit; } @@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); + apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); exit; } @@ -55,16 +55,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Validate filter name if (empty($filterName) || strlen($filterName) > 100) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid filter name']); + apiRespond(['success' => false, 'error' => 'Invalid filter name']); exit; } try { $result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault); - echo json_encode($result); + apiRespond($result); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to save filter']); + apiRespond(['success' => false, 'error' => 'Failed to save filter']); } exit; } @@ -75,7 +75,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { if (!isset($data['filter_id'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing filter_id']); + apiRespond(['success' => false, 'error' => 'Missing filter_id']); exit; } @@ -85,10 +85,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { if (isset($data['set_default']) && $data['set_default'] === true) { try { $result = $filtersModel->setDefaultFilter($filterId, $userId); - echo json_encode($result); + apiRespond($result); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); + apiRespond(['success' => false, 'error' => 'Failed to set default filter']); } exit; } @@ -96,7 +96,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { // Handle full filter update if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); + apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); exit; } @@ -106,10 +106,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { try { $result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault); - echo json_encode($result); + apiRespond($result); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to update filter']); + apiRespond(['success' => false, 'error' => 'Failed to update filter']); } exit; } @@ -120,7 +120,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['filter_id'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing filter_id']); + apiRespond(['success' => false, 'error' => 'Missing filter_id']); exit; } @@ -128,14 +128,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { try { $result = $filtersModel->deleteFilter($filterId, $userId); - echo json_encode($result); + apiRespond($result); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); + apiRespond(['success' => false, 'error' => 'Failed to delete filter']); } exit; } // Method not allowed http_response_code(405); -echo json_encode(['success' => false, 'error' => 'Method not allowed']); +apiRespond(['success' => false, 'error' => 'Method not allowed']); diff --git a/api/user_preferences.php b/api/user_preferences.php index 93151ea..7590a10 100644 --- a/api/user_preferences.php +++ b/api/user_preferences.php @@ -13,10 +13,10 @@ $prefsModel = new UserPreferencesModel($conn); if ($_SERVER['REQUEST_METHOD'] === 'GET') { try { $prefs = $prefsModel->getUserPreferences($userId); - echo json_encode(['success' => true, 'preferences' => $prefs]); + apiRespond(['success' => true, 'preferences' => $prefs]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']); + apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']); } exit; } @@ -46,10 +46,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']); } } - echo json_encode(['success' => true]); + apiRespond(['success' => true]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to save preferences']); + apiRespond(['success' => false, 'error' => 'Failed to save preferences']); } exit; } @@ -57,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Single preference: { key, value } if (!isset($data['key']) || !isset($data['value'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing key or value']); + apiRespond(['success' => false, 'error' => 'Missing key or value']); exit; } @@ -66,7 +66,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!in_array($key, $validKeys)) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid preference key']); + apiRespond(['success' => false, 'error' => 'Invalid preference key']); exit; } @@ -78,10 +78,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/'); } - echo json_encode(['success' => $success]); + apiRespond(['success' => $success]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to save preference']); + apiRespond(['success' => false, 'error' => 'Failed to save preference']); } exit; } @@ -92,20 +92,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['key'])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing key']); + apiRespond(['success' => false, 'error' => 'Missing key']); exit; } try { $success = $prefsModel->deletePreference($userId, $data['key']); - echo json_encode(['success' => $success]); + apiRespond(['success' => $success]); } catch (Exception $e) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Failed to delete preference']); + apiRespond(['success' => false, 'error' => 'Failed to delete preference']); } exit; } // Method not allowed http_response_code(405); -echo json_encode(['success' => false, 'error' => 'Method not allowed']); +apiRespond(['success' => false, 'error' => 'Method not allowed']); diff --git a/api/watch_ticket.php b/api/watch_ticket.php index c5026e7..c1a72e0 100644 --- a/api/watch_ticket.php +++ b/api/watch_ticket.php @@ -56,12 +56,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $countStmt->close(); - echo json_encode([ + apiRespond([ 'success' => true, 'watching' => $action === 'watch', 'watcher_count' => $count, ]); - exit; } // GET — return current watch state for this user diff --git a/assets/js/base.js b/assets/js/base.js index cd20236..95a8286 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -466,6 +466,7 @@ let data; try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); + if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; return data; } @@ -2702,6 +2703,7 @@ let data; try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; } if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status); + if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; return data; } api.get = url => _apiFetchAuth('GET', url);