Merge development into main: high-priority security/reliability batch (#27, #28, #30, #32)
Lint / PHP (phpcs PSR-12) (push) Successful in 29s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 25s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 6s

- Add missing rate limiting to create_ticket_api.php (#27)
- Fix visibility-group matching disagreement between filter and access check (#28)
- Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
- Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
This commit is contained in:
2026-09-08 21:33:33 -04:00
8 changed files with 129 additions and 32 deletions
+1 -1
View File
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
COALESCE(u.display_name, u.username, 'System') AS actor_name COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id LEFT JOIN users u ON al.user_id = u.user_id
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ? INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
WHERE al.action_type = 'update' WHERE al.action_type = 'update'
AND al.entity_type = 'ticket' AND al.entity_type = 'ticket'
AND al.user_id != ? AND al.user_id != ?
+20 -15
View File
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? []; $data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = isset($_GET['ticket_id']) $ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
? (int)$_GET['ticket_id']
: (int)($data['ticket_id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ticketId = (int)($data['ticket_id'] ?? 0); $ticketIdRaw = $data['ticket_id'] ?? '';
$action = $data['action'] ?? ''; $action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) { if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']); echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit; exit;
} }
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;
} }
// Use the canonical ticket_id string from the fetched ticket row, not the
// raw request value, so ticket_watchers always stores exactly what's in
// tickets.ticket_id.
$ticketId = $ticket['ticket_id'];
if ($action === 'watch') { if ($action === 'watch') {
$stmt = $conn->prepare( $stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)" "INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
); );
$stmt->bind_param("ii", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
} else { } else {
$stmt = $conn->prepare( $stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
); );
$stmt->bind_param("ii", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
} }
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$countStmt = $conn->prepare( $countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
); );
$countStmt->bind_param("i", $ticketId); $countStmt->bind_param("s", $ticketId);
$countStmt->execute(); $countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close(); $countStmt->close();
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
exit; exit;
} }
if ($ticketId <= 0) { if ($ticketIdRaw === '') {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']); echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit; exit;
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
// restricted ticket's watcher list and count aren't disclosed (the POST path // restricted ticket's watcher list and count aren't disclosed (the POST path
// already checks this). // already checks this).
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;
} }
$ticketId = $ticket['ticket_id'];
$watchingStmt = $conn->prepare( $watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
); );
$watchingStmt->bind_param("ii", $ticketId, $userId); $watchingStmt->bind_param("si", $ticketId, $userId);
$watchingStmt->execute(); $watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt']; $watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close(); $watchingStmt->close();
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
ORDER BY tw.created_at ASC ORDER BY tw.created_at ASC
LIMIT 6" LIMIT 6"
); );
$watchersStmt->bind_param("i", $ticketId); $watchersStmt->bind_param("s", $ticketId);
$watchersStmt->execute(); $watchersStmt->execute();
$watchersResult = $watchersStmt->get_result(); $watchersResult = $watchersStmt->get_result();
$watchers = []; $watchers = [];
@@ -118,7 +123,7 @@ $watchersStmt->close();
// True watcher count (the list above is capped at 6 for the avatar group) // True watcher count (the list above is capped at 6 for the avatar group)
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?"); $countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
$countStmt->bind_param("i", $ticketId); $countStmt->bind_param("s", $ticketId);
$countStmt->execute(); $countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close(); $countStmt->close();
+3
View File
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 0); ini_set('display_errors', 0);
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Load environment variables with error check // Load environment variables with error check
$envFile = __DIR__ . '/.env'; $envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) { if (!file_exists($envFile)) {
+2 -2
View File
@@ -204,9 +204,9 @@ class NotificationHelper
return; return;
} }
if ($excludeUserId !== null) { if ($excludeUserId !== null) {
$stmt->bind_param("ii", $ticketId, $excludeUserId); $stmt->bind_param("si", $ticketId, $excludeUserId);
} else { } else {
$stmt->bind_param("i", $ticketId); $stmt->bind_param("s", $ticketId);
} }
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
+3 -2
View File
@@ -243,11 +243,12 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
-- ============ ticket_watchers ============ -- ============ ticket_watchers ============
CREATE TABLE IF NOT EXISTS `ticket_watchers` ( CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`ticket_id` int(11) NOT NULL, `ticket_id` varchar(9) NOT NULL,
`user_id` int(11) NOT NULL, `user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(), `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`,`user_id`), PRIMARY KEY (`ticket_id`,`user_id`),
KEY `idx_watcher_user` (`user_id`) KEY `idx_watcher_user` (`user_id`),
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ tickets ============ -- ============ tickets ============
@@ -0,0 +1,28 @@
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
--
-- ticket_watchers.ticket_id was int(11), while every other satellite table
-- (ticket_comments, ticket_attachments, ticket_dependencies,
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
-- other satellite table, so orphaned watcher rows could never be caught by
-- referential integrity. Ticket IDs are always 9-digit numeric strings
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
-- conversion below is lossless for real data.
--
-- Safe to re-run.
-- Remove any watcher rows that no longer point at a real ticket (possible
-- today precisely because there was no FK to prevent it) before adding the
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
DELETE tw FROM `ticket_watchers` tw
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
WHERE t.`ticket_id` IS NULL;
ALTER TABLE `ticket_watchers`
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
ALTER TABLE `ticket_watchers`
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
ALTER TABLE `ticket_watchers`
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
+68 -11
View File
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
exit(1); exit(1);
} }
// Tracks per-statement progress within a migration file. MySQL DDL statements
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
// rollback() around a whole file can't actually undo DDL already executed
// earlier in that same file. This table lets a re-run after a partial failure
// resume from the statement after the last one that succeeded, instead of
// re-executing already-applied DDL and wedging on "already exists" errors.
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
last_statement_index INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)";
if (!$conn->query($createProgressTable)) {
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations // Get list of completed migrations
$completed = []; $completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id"); $result = $conn->query("SELECT filename FROM migrations ORDER BY id");
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
continue; continue;
} }
// Execute migration - handle multiple statements // Execute migration statement-by-statement, tracking progress as we go.
$conn->begin_transaction(); // No begin_transaction()/rollback() here: DDL statements auto-commit in
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
// file would only create the illusion of atomicity while giving no real
// protection. Instead, each statement commits immediately (autocommit),
// and its index is durably recorded so a later re-run can resume exactly
// where a previous run left off rather than re-executing already-applied
// DDL.
try { try {
// Split by semicolon but respect statements properly // Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple // Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter( $statements = array_values(array_filter(
array_map('trim', explode(';', $sql)), array_map('trim', explode(';', $sql)),
function($stmt) { function($stmt) {
// Remove comments and check if there's actual SQL // Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt); $cleaned = preg_replace('/--.*$/m', '', $stmt);
return !empty(trim($cleaned)); return !empty(trim($cleaned));
} }
); ));
$resumeFrom = 0;
$progressStmt = $conn->prepare(
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
);
$progressStmt->bind_param('s', $filename);
$progressStmt->execute();
$progressRow = $progressStmt->get_result()->fetch_assoc();
$progressStmt->close();
if ($progressRow) {
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
. " after a previous partial failure... ";
}
foreach ($statements as $index => $statement) {
if ($index < $resumeFrom) {
continue;
}
foreach ($statements as $statement) {
if (!$conn->query($statement)) { if (!$conn->query($statement)) {
// Some "errors" are acceptable (like "index already exists") // Some "errors" are acceptable (like "index already exists")
$error = $conn->error; $error = $conn->error;
if (strpos($error, 'Duplicate key name') !== false || if (strpos($error, 'Duplicate key name') !== false ||
strpos($error, 'already exists') !== false) { strpos($error, 'already exists') !== false) {
// Index already exists, that's fine // Index already exists, that's fine
continue; } else {
throw new Exception($error);
} }
throw new Exception($error);
} }
// Record progress after every statement so a later run can
// resume from here even if a subsequent statement fails.
$upsert = $conn->prepare(
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
);
$upsert->bind_param('si', $filename, $index);
$upsert->execute();
$upsert->close();
} }
// Record the migration // Record the migration as fully complete and clear its progress marker
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)"); $stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
$stmt->bind_param('s', $filename); $stmt->bind_param('s', $filename);
if (!$stmt->execute()) { if (!$stmt->execute()) {
throw new Exception("Could not record migration: " . $conn->error); throw new Exception("Could not record migration: " . $conn->error);
} }
$conn->commit(); $clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
$clearProgress->bind_param('s', $filename);
$clearProgress->execute();
$clearProgress->close();
echo "OK\n"; echo "OK\n";
$success++; $success++;
} catch (Exception $e) { } catch (Exception $e) {
$conn->rollback(); // Nothing to roll back: every statement up to the failure already
// committed (DDL implicitly, everything else via autocommit). The
// progress marker recorded above reflects exactly how far this file
// got, so the next run will resume right after the last success.
echo "FAILED (" . $e->getMessage() . ")\n"; echo "FAILED (" . $e->getMessage() . ")\n";
$failed++; $failed++;
} }
+4 -1
View File
@@ -726,7 +726,10 @@ class TicketModel
$groupConditions = []; $groupConditions = [];
foreach ($userGroups as $group) { foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))"; $groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group; // Strip spaces from the bound value too, matching the REPLACE()
// applied to the column, so a group name like "IT Support" is
// normalized the same way on both sides of the comparison.
$params[] = str_replace(' ', '', $group);
$types .= 's'; $types .= 's';
} }
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))"; $conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";