Audit fixes: security, dead code removal, API consolidation, JS dedup

Security:
- Fix IDOR in delete/update comment (add ticket visibility check)
- XSS defense-in-depth in DashboardView active filters
- Replace innerHTML with DOM construction in toast.js
- Remove redundant real_escape_string in check_duplicates
- Add rate limiting to get_template, download_attachment, audit_log,
  saved_filters, user_preferences endpoints

Bug fixes:
- Session timeout now reads from config instead of hardcoded 18000
- TicketController uses $GLOBALS['config'] instead of duplicate .env parsing
- Add DISCORD_WEBHOOK_URL to centralized config
- Cleanup script uses hashmap for O(1) ticket ID lookups

Dead code removal (~100 lines):
- Remove dead getTicketComments() from TicketModel (wrong bind_param type)
- Remove dead getCategories()/getTypes() from DashboardController
- Remove ~80 lines dead Discord webhook code from update_ticket API

Consolidation:
- Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit)
- Convert 6 API endpoints to use bootstrap
- Extract escapeHtml/getTicketIdFromUrl into shared utils.js
- Batch save for user preferences (1 request instead of 7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 14:50:06 -05:00
parent 15063838bd
commit bcc163bc77
26 changed files with 197 additions and 389 deletions

View File

@@ -1,36 +1,9 @@
<?php
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null;
@@ -41,9 +14,6 @@ if (!$ticketId) {
exit;
}
// Use centralized database connection
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);

View File

@@ -5,31 +5,16 @@
* Admin-only access
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// Check admin status - audit log viewing is admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
// Use centralized database connection
$conn = Database::getConnection();
$auditLogModel = new AuditLogModel($conn);
// GET - Fetch filtered audit logs or export to CSV
@@ -114,12 +99,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
}
$conn->close();
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

49
api/bootstrap.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
/**
* API Bootstrap - Common setup for API endpoints
*
* Provides: $conn, $currentUser, $userId, $isAdmin
*
* Usage:
* require_once __DIR__ . '/bootstrap.php';
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Config and database
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Authentication check
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF protection for write requests
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
header('Content-Type: application/json');
// Common variables
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$conn = Database::getConnection();

View File

@@ -5,22 +5,9 @@
* Searches for tickets with similar titles using LIKE and SOUNDEX
*/
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
ResponseHelper::error('Method not allowed', 405);
@@ -33,15 +20,12 @@ if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// Use centralized database connection
$conn = Database::getConnection();
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $conn->real_escape_string($title) . '%';
$searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);

View File

@@ -18,6 +18,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
@@ -61,9 +62,22 @@ try {
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Get comment before deletion for audit log
// Get comment before deletion for audit log and access check
$comment = $commentModel->getCommentById($commentId);
// Verify user can access the parent ticket
if ($comment) {
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ob_end_clean();
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
}
// Delete comment
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);

View File

@@ -5,7 +5,9 @@
* Serves file downloads for ticket attachments
*/
session_start();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';

View File

@@ -4,6 +4,9 @@
* Returns a ticket template by ID
*/
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();

View File

@@ -4,25 +4,9 @@
* Returns list of users for @mentions autocomplete
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once __DIR__ . '/bootstrap.php';
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication (session already started by RateLimitMiddleware)
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
header('Content-Type: application/json');
// Get all users for mentions/assignment
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");

View File

@@ -4,36 +4,9 @@
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Use centralized database connection
$conn = Database::getConnection();
$filtersModel = new SavedFiltersModel($conn);
// GET - Fetch all saved filters or a specific filter
@@ -72,7 +45,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']);
exit;
exit;
}
$filterName = trim($data['filter_name']);
@@ -83,7 +56,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
exit;
exit;
}
try {
@@ -103,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
exit;
exit;
}
$filterId = (int)$data['filter_id'];
@@ -117,14 +90,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
}
exit;
exit;
}
// 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']);
exit;
exit;
}
$filterName = trim($data['filter_name']);
@@ -148,7 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
exit;
exit;
}
$filterId = (int)$data['filter_id'];
@@ -166,5 +139,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

View File

@@ -18,6 +18,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
@@ -64,6 +65,20 @@ try {
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Verify user can access the parent ticket
$comment = $commentModel->getCommentById($commentId);
if ($comment) {
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ob_end_clean();
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
}
// Update comment
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);

View File

@@ -15,28 +15,7 @@ try {
$configPath = dirname(__DIR__) . '/config/config.php';
require_once $configPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
// Load environment variables (for Discord webhook)
$envPath = dirname(__DIR__) . '/.env';
$envVars = [];
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$envVars[$key] = $value;
}
}
}
// Load models directly with absolute paths
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
@@ -76,16 +55,14 @@ try {
private $commentModel;
private $auditLog;
private $workflowModel;
private $envVars;
private $userId;
private $isAdmin;
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) {
public function __construct($conn, $userId = null, $isAdmin = false) {
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->envVars = $envVars;
$this->userId = $userId;
$this->isAdmin = $isAdmin;
}
@@ -184,9 +161,6 @@ try {
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
}
// Discord webhook disabled for updates - only send for new tickets
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
return [
'success' => true,
'status' => $updateData['status'],
@@ -194,87 +168,6 @@ try {
'message' => 'Ticket updated successfully'
];
}
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
return;
}
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
// Determine what fields actually changed
$changes = [];
foreach ($changedFields as $field => $newValue) {
if ($field === 'ticket_id') continue; // Skip ticket_id
$oldValue = $oldData[$field] ?? 'N/A';
if ($oldValue != $newValue) {
$changes[] = [
'name' => ucfirst($field),
'value' => "$oldValue$newValue",
'inline' => true
];
}
}
if (empty($changes)) {
return;
}
// Create ticket URL using validated host
$ticketUrl = UrlHelper::ticketUrl($ticketId);
// Determine embed color based on priority
$colors = [
1 => 0xff4d4d, // Red
2 => 0xffa726, // Orange
3 => 0x42a5f5, // Blue
4 => 0x66bb6a, // Green
5 => 0x9e9e9e // Gray
];
$color = $colors[$newData['priority']] ?? 0x3498db;
$embed = [
'title' => '🔄 Ticket Updated',
'description' => "**#{$ticketId}** - " . $newData['title'],
'color' => $color,
'fields' => array_merge($changes, [
[
'name' => '🔗 View Ticket',
'value' => "[Click here to view]($ticketUrl)",
'inline' => false
]
]),
'footer' => [
'text' => 'Tinker Tickets'
],
'timestamp' => date('c')
];
$payload = [
'embeds' => [$embed]
];
// Send webhook
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
// Log webhook errors instead of silencing them
if ($curlError) {
error_log("Discord webhook cURL error for ticket #{$ticketId}: {$curlError}");
} elseif ($httpCode !== 204 && $httpCode !== 200) {
error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}, Response: " . substr($webhookResult, 0, 200));
}
}
}
// Use centralized database connection
@@ -300,7 +193,7 @@ try {
$ticketId = (int)$data['ticket_id'];
// Initialize controller
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
$controller = new ApiTicketController($conn, $userId, $isAdmin);
// Update ticket
$result = $controller->update($ticketId, $data);

View File

@@ -4,36 +4,9 @@
* Handles GET (fetch preferences) and POST (update preference)
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Use centralized database connection
$conn = Database::getConnection();
$prefsModel = new UserPreferencesModel($conn);
// GET - Fetch all preferences for user
@@ -48,19 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
exit;
}
// POST - Update a preference
// POST - Update preference(s)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
exit;
}
$key = trim($data['key']);
$value = $data['value'];
// Validate preference key (whitelist)
$validKeys = [
'rows_per_page',
@@ -71,10 +35,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'toast_duration'
];
// Support batch save: { preferences: { key: value, ... } }
if (isset($data['preferences']) && is_array($data['preferences'])) {
try {
foreach ($data['preferences'] as $key => $value) {
$key = trim($key);
if (!in_array($key, $validKeys)) continue;
$prefsModel->setPreference($userId, $key, $value);
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
}
}
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']);
}
exit;
}
// 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']);
exit;
}
$key = trim($data['key']);
$value = $data['value'];
if (!in_array($key, $validKeys)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
exit;
exit;
}
try {
@@ -100,7 +93,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']);
exit;
exit;
}
try {
@@ -116,5 +109,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>