fix: CSRF on ticket create form, DOM-safe duplicate list, audit-log param validation

- TicketController::create: validate csrf_token from POST before processing
- CreateTicketView: emit hidden csrf_token field; replace innerHTML duplicate
  list with DOM methods to prevent any XSS path; guard checkDuplicates() with
  lt.api availability check
- index.php audit-log: allowlist action_type; validate date_from/date_to as
  YYYY-MM-DD before passing to query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 21:26:52 -04:00
parent b40c404828
commit 6b76496640
3 changed files with 44 additions and 12 deletions
+12
View File
@@ -75,6 +75,18 @@ class TicketController {
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_POST['csrf_token'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$error = "Invalid or expired security token. Please try again.";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
+6 -4
View File
@@ -242,7 +242,9 @@ switch (true) {
$params = [];
$types = '';
if (!empty($_GET['action_type'])) {
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
@@ -252,15 +254,15 @@ switch (true) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = $_GET['user_id'];
$filters['user_id'] = (int)$_GET['user_id'];
}
if (!empty($_GET['date_from'])) {
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
+26 -8
View File
@@ -35,6 +35,9 @@ include __DIR__ . '/layout_header.php';
class="create-ticket-form"
novalidate>
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
<?php if (isset($error)): ?>
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
@@ -255,26 +258,41 @@ include __DIR__ . '/layout_header.php';
});
function checkDuplicates(title) {
if (!window.lt || typeof lt.api === 'undefined') return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(function (data) {
var warn = document.getElementById('duplicateWarning');
var list = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) {
var html = '<ul class="duplicate-list lt-text-sm">';
var ul = document.createElement('ul');
ul.className = 'duplicate-list lt-text-sm';
data.duplicates.forEach(function (dup) {
html += '<li><a href="/ticket/' + lt.escHtml(dup.ticket_id) + '" target="_blank">#' +
lt.escHtml(dup.ticket_id) + '</a> &mdash; ' + lt.escHtml(dup.title) +
' <span class="lt-text-muted">(' + dup.similarity + '% match, ' +
lt.escHtml(dup.status) + ')</span></li>';
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
a.target = '_blank';
a.textContent = '#' + dup.ticket_id;
var dash = document.createTextNode(' \u2014 ' + dup.title + ' ');
var badge = document.createElement('span');
badge.className = 'lt-text-muted';
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
li.appendChild(a);
li.appendChild(dash);
li.appendChild(badge);
ul.appendChild(li);
});
html += '</ul><p class="lt-text-xs lt-text-muted lt-mt-sm">Check these before creating a new ticket.</p>';
list.innerHTML = html;
var hint = document.createElement('p');
hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
hint.textContent = 'Check these before creating a new ticket.';
list.innerHTML = '';
list.appendChild(ul);
list.appendChild(hint);
warn.classList.remove('is-hidden');
} else {
warn.classList.add('is-hidden');
}
})
.catch(function () { /* silent */ });
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Visibility groups toggle ──────────────────────────────